Compare commits

...

34 Commits

Author SHA1 Message Date
9eef567974 feat: simple related media block on results page 2025-08-29 16:29:20 -05:00
070723581a fix: view all monitors button color 2025-08-29 15:13:30 -05:00
f3a5c2012e fix: calendar icon on mobile 2025-08-29 01:08:47 -05:00
5581a82554 fix: ical subscription not loading 2025-08-28 20:19:31 -05:00
3703272f59 fix: makes ical url publicly accessible if user has option enabled 2025-08-27 22:58:24 -05:00
b587302b30 wip: ical calendar export 2025-08-26 22:24:23 -05:00
e5bab8e6fd fix: adds calendar button to link to upcoming episodes, adds titles to A tags 2025-08-25 23:06:30 -05:00
502b85dda4 fix: typo in default NTFY_DSN env var 2025-08-24 13:08:20 -05:00
9c430290e9 fix: makes calendar responsive 2025-08-23 22:18:01 -05:00
583591bf4f fix: applies colors to calendar events 2025-08-23 21:43:44 -05:00
182708b8f0 fix: links to upcoming episodes page 2025-08-23 14:54:04 -05:00
d6ba4d7d2a fix: updates episode air date for existing monitors 2025-08-23 14:37:24 -05:00
e5c5ec93a8 feat: /api/upcoming-episodes 2025-08-23 14:14:37 -05:00
942911d8ef fix: removes fullcalendar from importmap and references from cdn 2025-08-23 12:26:26 -05:00
2f7d406d12 wip: renders calendar with demo data 2025-08-23 09:22:02 -05:00
4e1adc576c fix: disables pull to refresh 2025-08-09 00:37:13 -05:00
575fc08f24 fix: cleanup 2025-08-09 00:24:31 -05:00
87bdde801d fix: updates config resolver 2025-08-08 23:48:30 -05:00
7d35b6266b feat: ntfy integration 2025-08-08 23:38:53 -05:00
caeda625fd fix: ships logs to graylog 2025-08-08 23:29:50 -05:00
d710e31d2b fix(MonitorList): no monitors row 2025-08-08 23:05:45 -05:00
39a64faa74 fix: styles 2025-08-06 15:10:47 -05:00
c6a84df2fd fix: filter preferences options behind div 2025-08-06 12:34:46 -05:00
a7273cf2e5 fix: includes '-' as filter option for each filter 2025-08-04 14:49:22 -05:00
c9cfa5e427 fix: typo 2025-08-04 14:36:49 -05:00
cb50007208 fix: adds preview content modal to monitor tables 2025-08-04 14:35:27 -05:00
62aa0f4554 fix: uses polyfill to fix web components on safari 2025-08-03 12:45:38 -05:00
08e376babc fix: download data preview modal style 2025-08-02 23:01:39 -05:00
2becc98d61 feat: download data preview modal 2025-08-02 22:46:21 -05:00
0430dba6a9 fix: bad PTN parsing causing monitors of episodes with 3 digit episodes to download multiple times 2025-08-01 21:34:03 -05:00
beed7d6940 fix: season/episode regex parser 2025-08-01 15:21:05 -05:00
924472ed56 fix: applies pre-filter to download options 2025-08-01 14:12:28 -05:00
7dd61355b7 fix: backend download option evaluator used by monitor service 2025-08-01 14:10:33 -05:00
2a1f69edd4 fix: multi-choice filter styles 2025-07-26 18:31:48 -05:00
88 changed files with 3531 additions and 273 deletions

7
.env
View File

@@ -51,3 +51,10 @@ OIDC_CLIENT_ID="Enter your OIDC client id"
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
OIDC_BYPASS_FORM_LOGIN=false
###< drenso/symfony-oidc-bundle ###
###> symfony/ntfy-notifier ###
# NTFY_DSN=ntfy://default/TOPIC
###< symfony/ntfy-notifier ###
NOTIFICATION_TRANSPORT=
NTFY_DSN=

3
.env.test Normal file
View File

@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

5
.gitignore vendored
View File

@@ -19,3 +19,8 @@ bolt.db
phpstan.neon
###< phpstan/phpstan ###
.php-cs-fixer.cache
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

View File

@@ -18,11 +18,3 @@ var observer = new MutationObserver(function(mutations) {
});
observer.observe(document, {attributes: false, childList: true, characterData: false, subtree:true});
const ptr = PullToRefresh.init({
mainElement: 'body',
onRefresh() {
window.location.reload();
}
});

7
assets/bootstrap.js vendored
View File

@@ -1,5 +1,9 @@
import '@ungap/custom-elements'
import PreviewContentDialog from "./components/preview-content-dialog.js";
import EpisodeContainer from './components/episode-container.js';
import DownloadOptionTr from './components/download-option-tr.js';
import DownloadListRow from './components/download-list-row.js';
import MonitorListRow from './components/monitor-list-row.js';
import MovieContainer from "./components/movie-container.js";
import { startStimulusApp } from '@symfony/stimulus-bundle';
@@ -14,6 +18,9 @@ app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
customElements.define('preview-content-dialog', PreviewContentDialog, {extends: 'dialog'});
customElements.define('episode-container', EpisodeContainer);
customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});

View File

@@ -0,0 +1,111 @@
export default class DownloadListRow extends HTMLTableRowElement {
constructor() {
super();
this.downloadId = this.getAttribute('download-id');
this.imdbId = this.getAttribute('imdb-id');
this.mediaTitle = this.getAttribute('media-title');
this.url = this.getAttribute('url');
this.filename = this.getAttribute('filename');
this.status = this.getAttribute('status');
this.progress = this.getAttribute('progress');
this.mediaType = this.getAttribute('media-type');
this.episodeId = this.getAttribute('episode-id');
this.createdAt = this.getAttribute('created-at');
this.updatedAt = this.getAttribute('updated-at');
// this.previewContent = this.previewContent.bind(this);
}
static get observedAttributes() {
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue;
this.setAttribute(name, newValue);
this.setPreviewContent();
}
}
setPreviewContent() {
this.previewContent = `
<table class="table-auto flex flex-row">
<thead>
<tr class="flex flex-col">
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">URL</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Filename</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Progress</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
</th>
</tr>
</thead>
<tbody>
<tr class="flex flex-col">
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('download-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('url') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('filename') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('progress') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('updated-at') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>
`;
}
}

View File

@@ -0,0 +1,115 @@
export default class MonitorListRow extends HTMLTableRowElement {
constructor() {
super();
this.downloadId = this.getAttribute('monitor-id');
this.imdbId = this.getAttribute('imdb-id');
this.mediaTitle = this.getAttribute('media-title');
this.url = this.getAttribute('url');
this.filename = this.getAttribute('filename');
this.status = this.getAttribute('status');
this.progress = this.getAttribute('progress');
this.mediaType = this.getAttribute('media-type');
this.episodeId = this.getAttribute('episode-id');
this.createdAt = this.getAttribute('created-at');
this.updatedAt = this.getAttribute('updated-at');
}
static get observedAttributes() {
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue;
this.setAttribute(name, newValue);
this.setPreviewContent();
}
}
setPreviewContent() {
this.previewContent = `
<table class="table-auto flex flex-row">
<thead>
<tr class="flex flex-col">
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Season</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Search Count</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Downloaded At</div>
</th>
</tr>
</thead>
<tbody>
<tr class="flex flex-col">
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('monitor-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('season') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('search-count') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('last-search') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('downloaded-at') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>
`;
}
}

View File

@@ -0,0 +1,35 @@
export default class PreviewContentDialog extends HTMLDialogElement {
#headingEl;
#contentEl;
#closeBtnEl;
constructor() {
super();
this.#headingEl = this.querySelector('.modal-heading');
this.#contentEl = this.querySelector('.modal-content');
this.#closeBtnEl = this.querySelector('.modal-close');
this.setHeading = this.setHeading.bind(this);
this.setContent = this.setContent.bind(this);
this.#closeBtnEl.addEventListener('click', () => this.close());
document.addEventListener('hidePreviewContentModal', () => this.close());
document.addEventListener('showPreviewContentModal', (event) => {
this.display(event.detail);
});
}
setHeading(heading) {
this.#headingEl.innerHTML = heading;
}
setContent(content) {
this.#contentEl.innerHTML = content;
}
display({ heading, content }) {
this.setHeading(heading);
this.setContent(content);
this.showModal();
}
}

View File

@@ -20,13 +20,43 @@ export default class extends Controller {
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
// this.element.addEventListener('click', (event) => {
// let previewContentModal = document.querySelector('#previewContentModal');
// // previewContentModal.setHeading(event.target.dataset['title']);
// // previewContentModal.setContent('<p>Testing this here thingy-ma-bob!</p>');
// // previewContentModal.showModal();
// let content, heading = ""
// if (event.target.tagName !== "TR") {
// content = event.target.parentElement.previewContent();
// heading = event.target.parentElement.mediaTitle;
// } else {
// content = event.target.previewContent();
// heading = event.target.mediaTitle;
// }
//
// document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
// })
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
downloads.forEach(download => {
download.addEventListener('click', (event) => {
let content, heading = ""
if (event.target.tagName !== "TR") {
content = event.target.parentElement.previewContent;
heading = "Download # " + event.target.parentElement.downloadId + " - \"" + event.target.parentElement.mediaTitle + "\"";
} else {
content = event.target.previewContent;
heading = "Download # " + event.target.downloadId + " - \"" + event.target.mediaTitle + "\"";
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
})
})
}
pauseDownload(data) {

View File

@@ -41,6 +41,8 @@ export default class extends Controller {
if (this.countValue === this.totalValue) {
this.toggleIcon();
this.countValue = 0;
console.log('filtering')
document.getElementById('filter').filterResults();
}
}
}

View File

@@ -6,6 +6,29 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['monitorList']
monitorListTargetConnected(target) {
let monitors = this.element.querySelectorAll('tbody tr');
monitors.forEach(monitor => {
monitor.addEventListener('click', (event) => {
let content, heading = ""
if (event.target.tagName !== "TR") {
content = event.target.parentElement.previewContent;
heading = "Monitor for \"" + event.target.parentElement.mediaTitle+ "\"";
} else {
content = event.target.previewContent;
heading = "Monitor for \"" + event.target.mediaTitle + "\"";
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
})
})
}
deleteMonitor(data) {
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())

View File

@@ -12,14 +12,14 @@ export default class extends Controller {
seasons = []
activeFilter = {
"resolution": "",
"codec": "",
"language": "",
"provider": "",
"quality": "",
"resolution": [],
"codec": [],
"language": [],
"provider": [],
"quality": [],
}
defaultOptions = '<option value="">n/a</option><option value="-">-</option>';
defaultOptions = '<option value="-">-</option>';
static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected']
@@ -31,13 +31,21 @@ export default class extends Controller {
}
async connect() {
await this.setInitialFilter();
this.setTimerToStopLoadingIcon();
this.element.filterResults = this.filter.bind(this);
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
}
async setInitialFilter() {
const response = await fetch('/api/user/filters');
const filters = await response.json();
if (filters.length > 0) {
this.activeFilter = filters[0];
}
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;
}
this.filter();
this.setTimerToStopLoadingIcon();
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
}
setTimerToStopLoadingIcon() {
@@ -45,13 +53,10 @@ export default class extends Controller {
}
// Event is fired from movies/tvshows controllers to populate this data
loadOptions({detail: { options }}) {
options.forEach((option) => {
this.addLanguages(option);
this.addProviders(option);
this.addQualities(option);
async loadOptions({detail: { options }}) {
await options.forEach((option) => {
option.filter({detail: {activeFilter: this.activeFilter }});
})
this.filter();
}
selectAllEpisodes() {
@@ -66,41 +71,6 @@ export default class extends Controller {
document.dispatchEvent(new CustomEvent('downloadSelectedEpisodes', {}));
}
addLanguages(option) {
option.languages.forEach((language) => {
if (!this.languages.includes(language)) {
this.languages.push(language);
}
});
const preferred = JSON.parse(this.languageTarget.dataset.preferred) ?? [];
this.languageTarget.innerHTML = this.#serializeSelectOptions(this.languages);
this.languageTarget.tomselect.items = preferred;
}
addProviders(option) {
if (!this.providers.includes(option.provider)) {
this.providers.push(option.provider);
}
const preferred = JSON.parse(this.providerTarget.dataset.preferred) ?? [];
this.providerTarget.innerHTML = this.#serializeSelectOptions(this.providers);
this.providerTarget.tomselect.items = preferred;
}
addQualities(option) {
if (option.quality.toLowerCase() in this.reverseMappedQualitiesValue) {
let quality = this.reverseMappedQualitiesValue[option.quality.toLowerCase()];
// converts api returned quality with a value the system recognizes
option.quality = quality;
if (!this.qualities.includes(option.quality)) {
this.qualities.push(quality);
}
}
const preferred = JSON.parse(this.qualityTarget.dataset.preferred) ?? [];
this.qualityTarget.innerHTML = this.#serializeSelectOptions(this.qualities);
this.qualityTarget.tomselect.items = preferred;
}
filter() {
const downloadSeasonSpan = document.querySelector("#downloadSeasonModal");

View File

@@ -30,11 +30,11 @@ export default class extends Controller {
option.querySelector('.download-btn').dataset['title'] = this.titleValue
);
this.element.options[0].querySelector('input[type="checkbox"]').checked = true;
this.loadingIconOutlet.increaseCount();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.element.options}}));
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
}
}

View File

@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
calendar = null;
initialize() {
}
connect() {
this.calendar = new Calendar(this.element, {
plugins: [ dayGridPlugin, timeGridPlugin ],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay'
},
editable: true, // Allow events to be dragged and resized
events: '/api/events', // Symfony route to fetch events
eventDrop: function(info) {
// Handle event drop (e.g., update event in database via AJAX)
},
eventResize: function(info) {
// Handle event resize (e.g., update event in database via AJAX)
}
});
this.calendar.render();
// this.calendar = new Calendar(this.element, {
// plugins: [ dayGridPlugin, timeGridPlugin, listPlugin ],
// initialView: 'dayGridMonth',
// headerToolbar: {
// left: 'prev,next today',
// center: 'title',
// right: 'dayGridMonth,timeGridWeek,listWeek'
// }
// });
// this.calendar.render();
// calendar.render();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor"><path d="M19.5 9.5v-.8c0-1.12 0-1.68-.218-2.108a2 2 0 0 0-.874-.874C17.98 5.5 17.42 5.5 16.3 5.5H7.7c-1.12 0-1.68 0-2.108.218a2 2 0 0 0-.874.874C4.5 7.02 4.5 7.58 4.5 8.7v.8m15 0v6.8c0 1.12 0 1.68-.218 2.108a2 2 0 0 1-.874.874c-.428.218-.988.218-2.108.218H7.7c-1.12 0-1.68 0-2.108-.218a2 2 0 0 1-.874-.874C4.5 17.98 4.5 17.42 4.5 16.3V9.5m15 0h-15"/><path stroke-linecap="round" d="M8.5 3.5v4m7-4v4M12 17v-5m2.5 2.5h-5"/></g></svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none"><path stroke="currentColor" stroke-width="1.5" d="M2 12c0-3.771 0-5.657 1.172-6.828S6.229 4 10 4h4c3.771 0 5.657 0 6.828 1.172S22 8.229 22 12v2c0 3.771 0 5.657-1.172 6.828S17.771 22 14 22h-4c-3.771 0-5.657 0-6.828-1.172S2 17.771 2 14z"/><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M7 4V2.5M17 4V2.5M2.5 9h19"/><path fill="currentColor" d="M18 17a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-5 4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m-5 4a1 1 0 1 1-2 0a1 1 0 0 1 2 0m0-4a1 1 0 1 1-2 0a1 1 0 0 1 2 0"/></g></svg>

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -27,6 +27,11 @@
}
}
:root {
--fc-border-color: #a65b27;
--fc-page-bg-color: #a65b27;
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
@@ -55,6 +60,10 @@ dialog {
}
}
dialog[open] {
animation: fade-in 100ms ease-in forwards;
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;
@@ -133,7 +142,7 @@ dialog[data-dialog-target="dialog"][closing] {
#search .ts-dropdown {
background: unset;
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md z-20
}
#search .ts-dropdown .ts-dropdown-content .option.active {
@@ -165,9 +174,9 @@ dialog[data-dialog-target="dialog"][closing] {
padding: 0;
.ts-control {
background: transparent !important;
border: none !important;
box-shadow: none !important;
@apply bg-orange-500/60 backdrop-filter backdrop-blur-md;
}
.item[data-ts-item] {
@@ -175,10 +184,10 @@ dialog[data-dialog-target="dialog"][closing] {
border: none;
box-shadow: none;
text-shadow: none;
@apply bg-orange-500 rounded-ms font-bold;
@apply bg-orange-500 rounded-ms font-bold text-black;
}
@apply border-b-2 border-b-orange-600 bg-transparent;
@apply border border-orange-500 bg-transparent rounded-ms;
}
.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove {
@@ -189,3 +198,7 @@ dialog[data-dialog-target="dialog"][closing] {
.filter-label {
@apply flex flex-col gap-1 justify-between;
}
.fc-col-header-cell {
@apply bg-orange-500/60 text-white;
}

23
bin/phpunit Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

View File

@@ -27,6 +27,7 @@
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"spatie/icalendar-generator": "^3.0",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
@@ -43,6 +44,8 @@
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
@@ -118,6 +121,7 @@
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12.3",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"

1769
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
framework:
notifier:
chatter_transports:
texter_transports:
ntfy: '%notification.ntfy.dsn%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }

View File

@@ -45,6 +45,7 @@ security:
# Easy way to control access for large sections of your site
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/monitors/ical/, roles: PUBLIC_ACCESS }
- { path: ^/reset-password, roles: PUBLIC_ACCESS }
- { path: ^/getting-started, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }

View File

@@ -44,6 +44,10 @@ parameters:
auth.oidc.client_secret: '%env(OIDC_CLIENT_SECRET)%'
auth.oidc.bypass_form_login: '%env(bool:OIDC_BYPASS_FORM_LOGIN)%'
# Notifications
notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
notification.ntfy.dsn: '%env(NTFY_DSN)%'
services:
# default configuration for services in *this* file
_defaults:

View File

@@ -6,7 +6,6 @@ services:
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
deploy:
replicas: 2
volumes:
@@ -16,6 +15,10 @@ services:
- mercure_config:/config
depends_on:
- database
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12202"
worker:
@@ -29,6 +32,10 @@ services:
replicas: 2
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12203"
scheduler:
@@ -40,6 +47,11 @@ services:
command: -vv
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12204"
redis:

View File

@@ -1,20 +1,6 @@
FROM dunglas/frankenphp:php8.4-alpine
ARG APP_VERSION
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app
FROM code.caldwell.digital/torsearch/torsearch-app:${APP_VERSION}
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]

View File

@@ -67,4 +67,7 @@ return [
'pulltorefreshjs' => [
'version' => '0.1.22',
],
'@ungap/custom-elements' => [
'version' => '1.3.0',
],
];

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250823173128 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD air_date DATETIME DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP air_date
SQL);
}
}

44
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

View File

@@ -38,6 +38,12 @@ final class ConfigResolver
#[Autowire(param: 'auth.oidc.bypass_form_login')]
private ?bool $authOidcBypassFormLogin = null,
#[Autowire(param: 'notification.transport')]
private ?string $notificationTransport = null,
#[Autowire(param: 'notification.ntfy.dsn')]
private ?string $notificationNtfyDsn = null,
) {}
public function validate(): bool
@@ -54,6 +60,12 @@ final class ConfigResolver
$valid = false;
}
if (null !== $this->notificationTransport) {
if (null === $this->notificationNtfyDsn || "" === $this->notificationNtfyDsn) {
$this->messages[] = "Your NOTIFICATION_TRANSPORT is set to 'ntfy' but you don't have the NTFY_DSN environment variable set.";
}
}
return $valid;
}

View File

@@ -135,6 +135,13 @@ class SeedDatabaseCommand extends Command
'enabled' => true,
'type' => 'download'
],
[
'id' => 'enable_ical_up_ep',
'name' => 'Enable a publicly available iCal calendar?',
'description' => 'Enable a publicly accessible iCal URL for your upcoming episodes.',
'enabled' => false,
'type' => 'calendar'
],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Base\Framework\Controller;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb;
use App\User\Framework\Entity\User;
@@ -48,4 +49,10 @@ final class IndexController extends AbstractController
'message' => 'Email sent!'
]);
}
#[Route('/test')]
public function monitorTvShow(): Response
{
return $this->render('index/test.html.twig', []);
}
}

View File

@@ -2,33 +2,53 @@
namespace App\Base\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Twig\Environment;
readonly class Broadcaster
{
public function __construct(
#[Autowire(param: 'notification.transport')]
private string $notificationTransport,
#[Autowire(service: 'twig')]
private Environment $renderer,
private HubInterface $hub,
private RequestStack $requestStack,
private NotifierInterface $notifier,
private LoggerInterface $logger,
) {}
public function alert(string $title, string $message, string $type = "success"): void
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
try {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
} catch (\Throwable $exception) {
// ToDo: look for better handling to get message to end user
}
if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {
try {
$notification = new Notification($title, ['push'])->content($message);
$this->notifier->send($notification);
} catch (\Throwable $exception) {
$this->logger->error('Unable to send push notification: ' . $exception->getMessage());
}
}
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Base\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;

246
src/Base/Util/PTN.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
/**
* Parse Torrent Name (PTN)
*
* PHP port of parse-torrent-name written in Python.
*
* Javascript version by jzjzjzj
* https://github.com/jzjzjzj/parse-torrent-name
*
* Python version by divijbindlish
* https://github.com/divijbindlish/parse-torrent-name
*
* Copyright (c) 2014 - 2018, British Columbia Institute of Technology
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* @package PTN
* @author Drew Smith
* @copyright copyright (c) 2018, Nihilarr (https://www.nihilarr.com)
* @license http://opensource.org/licenses/MIT MIT License
* @link https://gitlab.com/nihilarr/parse-torrent-name
* @version 0.0.1
*/
namespace App\Base\Util;
class PTN {
public $torrent;
public $excess_raw;
public $group_raw;
public $start;
public $end;
public $title_raw;
public $parts;
public $patterns = array(
array('season' => '(s?([0-9]{1,3}))[ex]'),
array('episode' => '([ex]([0-9]{1,3})(?:[^0-9]|$))'),
array('year' => '([\[\(]?((?:19[0-9]|20[01])[0-9])[\]\)]?)'),
array('resolution' => '([0-9]{3,4}p)'),
array('quality' => '((?:PPV\.)?[HP]DTV|(?:HD)?CAM|B[DR]Rip|(?:HD-?)?TS|(?:PPV )?WEB-?DL(?: DVDRip)?|HDRip|DVDRip|DVDRIP|CamRip|W[EB]BRip|BluRay|DvDScr|hdtv|telesync)'),
array('codec' => '(xvid|[hx]\.?26[45])'),
array('audio' => '(MP3|DD5\.?1|Dual[\- ]Audio|LiNE|DTS|AAC[.-]LC|AAC(?:\.?2\.0)?|AC3(?:\.5\.1)?)'),
array('group' => '(- ?([^-]+(?:-={[^-]+-?$)?))$'),
array('region' => 'R[0-9]'),
array('extended' => '(EXTENDED(:?.CUT)?)'),
array('hardcoded' => 'HC'),
array('proper' => 'PROPER'),
array('repack' => 'REPACK'),
array('container' => '(MKV|AVI|MP4)'),
array('widescreen' => 'WS'),
array('website' => '^(\[ ?([^\]]+?) ?\])'),
array('language' => '(rus\.eng|ita\.eng)'),
array('sbs' => '(?:Half-)?SBS'),
array('unrated' => 'UNRATED'),
array('size' => '(\d+(?:\.\d+)?(?:GB|MB))'),
array('3d' => '3D')
);
public $types = array(
'season' => 'integer',
'episode' => 'integer',
'year' => 'integer',
'extended' => 'boolean',
'hardcoded' => 'boolean',
'proper' => 'boolean',
'repack' => 'boolean',
'widescreen' => 'boolean',
'unrated' => 'boolean',
'3d' => 'boolean'
);
public function __construct() {}
public function parse($name) {
$this->parts = array();
$this->torrent = array('name' => $name);
$this->excess_raw = $name;
$this->group_raw = '';
$this->start = 0;
$this->end = null;
$this->title_raw = null;
foreach($this->patterns as $patterns_single) {
foreach($patterns_single as $key => $pattern) {
if(!in_array($key, array('season', 'episode', 'website'))) {
$pattern = "\b{$pattern}\b";
}
$clean_name = str_replace('_', ' ', $this->torrent['name']);
if(preg_match("/{$pattern}/i", $clean_name, $match) == 0) break;
$index = array();
if(is_array($match)) {
array_shift($match);
}
if(sizeof($match) == 0) break;
if(sizeof($match) > 1) {
$index['raw'] = 0;
$index['clean'] = 1;
}
else {
$index['raw'] = 0;
$index['clean'] = 0;
}
if(isset($this->types[$key]) && $this->types[$key] == 'boolean') {
$clean = true;
}
else {
$clean = $match[$index['clean']];
if(isset($this->types[$key]) && $this->types[$key] == 'integer') {
$clean = (int)$clean;
}
}
if($key == 'group') {
if((isset($this->patterns[5][1]) && preg_match_all("/{$this->patterns[5][1]}/i", $clean)) ||
(isset($this->patterns[4][1]) && preg_match_all("/{$this->patterns[4][1]}/", $clean))) {
break;
}
if(preg_match('/[^ ]+ [^ ]+ .+/', $clean)) {
$key = 'episodeName';
}
}
if($key == 'episode') {
$sub_pattern = $this->escape_regex($match[$index['raw']]);
$this->torrent['map'] = preg_replace("/{$sub_pattern}/", '{episode}', $this->torrent['name']);
}
$this->part($key, $match, $match[$index['raw']], $clean);
}
}
$raw = $this->torrent['name'];
if(!is_null($this->end)) {
$raw = explode('(', substr($raw, $this->start, $this->end - $this->start));
$raw = $raw[0];
}
$clean = preg_replace("/^ -/", '', $raw);
if(strpos($clean, ' ') === false && strpos($clean, '.') !== false) {
$clean = str_replace('.', ' ', $clean);
}
$clean = str_replace('_', ' ', $clean);
$clean = trim(preg_replace("/([\[\(_]|- )$/", '', $clean));
$this->part('title', array(), $raw, $clean);
$clean = preg_replace("/(^[-\. ()]+)|([-\. ]+$)/", '', $this->excess_raw);
$clean = preg_replace("/[\(\)\/]/", ' ', $clean);
$match = preg_split("/\.\.+| +/", $clean);
if(sizeof($match) > 0 && is_array($match[0])) {
$match = $match[0];
}
$clean = $match;
$clean = array_filter($clean, function($var) {
return $var != '-' ? true : false;
});
$clean = array_filter($clean, function($var) {
return trim($var, '-');
});
$clean = array_values($clean);
if(sizeof($clean) > 0) {
$group_pattern = $clean[sizeof($clean) - 1] . $this->group_raw;
if(strpos($this->torrent['name'], $group_pattern) == strlen($this->torrent['name']) - strlen($group_pattern)) {
$this->late('group', array_pop($clean) . $this->group_raw);
}
if(isset($this->torrent['map']) && sizeof($clean) > 0) {
$episode_name_pattern = '{episode}' . preg_replace("/_+$/", '', $clean[0]);
if(strpos($this->torrent['map'], $episode_name_pattern) != -1) {
$this->late('episodeName', array_shift($clean));
}
}
}
if(sizeof($clean) != 0) {
if(sizeof($clean) == 1) {
$clean = $clean[0];
}
$this->part('excess', array(), $this->excess_raw, $clean);
}
return $this->parts;
}
private function escape_regex($subject) {
return preg_replace("/[\-\[\]{}()*+?.,\\\^$|#\s]/", "\\\\$&", $subject);
}
private function part($name, $match, $raw, $clean) {
# The main core instructuions
$this->parts[$name] = $clean;
if(sizeof($match) > 0) {
# The instructions for extracting title
$index = strpos($this->torrent['name'], $match[0]);
if($index == 0) {
$this->start = strlen($match[0]);
}
elseif(is_null($this->end) || $index < $this->end) {
$this->end = $index;
}
}
if($name != 'excess') {
if($name == 'group') {
$this->group_raw = $raw;
}
if(!is_null($raw)) {
$this->excess_raw = str_replace($raw, '', $this->excess_raw);
}
}
}
private function late($name, $clean) {
if($name == 'group') {
$this->part($name, array(), null, $clean);
}
elseif($name == 'episodeName') {
$clean = preg_replace("/[\._]/", ' ', $clean);
$clean = preg_replace("/_+$/", '', $clean);
$this->part($name, array(), null, trim($clean));
}
}
}

View File

@@ -14,7 +14,7 @@ use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Repository\UserRepository;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

View File

@@ -3,92 +3,80 @@
namespace App\Download;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Result\TorrentioResult;
use App\User\Dto\UserPreferences;
class DownloadOptionEvaluator
{
/**
* @param Monitor $monitor
* @param TorrentioResult[] $results
* @param UserPreferences $filter
* @return TorrentioResult|null
* @throws \Throwable
*/
public function evaluateOptions(array $results, UserPreferences $userPreferences): ?TorrentioResult
public function evaluateOptions(array $results, UserPreferences $filter): ?TorrentioResult
{
$sizeLow = 000;
$sizeHigh = 4096;
$bestMatches = [];
$matches = [];
foreach ($results as $result) {
if (!in_array($userPreferences->language, $result->languages)) {
continue;
$matches = Map::from($results)->filter(function ($result) use ($filter) {
if (false === $this->validateFilterItems($result, $filter)) {
return false;
}
if ($result->resolution === $userPreferences->resolution
&& $result->codec === $userPreferences->codec
) {
$bestMatches[] = $result;
if (false === $this->validateSize($result, $filter)) {
return false;
}
if ($userPreferences->resolution === '2160p'
&& $userPreferences->codec === $result->codec
&& $result->resolution === '1080p'
) {
$matches[] = $result;
}
return true;
});
if ($userPreferences->codec === 'h264'
&& $userPreferences->resolution === $result->resolution
&& $result->codec === 'h265'
) {
$matches[] = $result;
}
if (($userPreferences->codec === null )
&& ($userPreferences->resolution === null )) {
$matches[] = $result;
}
}
$sizeMatches = [];
foreach ($bestMatches as $result) {
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
foreach ($matches as $result) {
$size = 0;
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
if ($matches->count() > 0) {
return Map::from($matches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
return null;
}
private function validateFilterItems(TorrentioResult $result, UserPreferences $filter): bool
{
if (array_intersect($filter->language, $result->languages) === []) {
return false;
}
$valid = true;
if (null !== $filter->resolution && !in_array($result->resolution, $filter->resolution)) {
$valid = false;
}
if (null !== $filter->codec && in_array($result->codec, $filter->codec)) {
$valid = false;
}
if (null !== $filter->quality && in_array($result->quality, $filter->quality)) {
$valid = false;
}
if (null !== $filter->provider && in_array($result->provider, $filter->provider)) {
$valid = false;
}
return $valid;
}
private function validateSize(TorrentioResult $result, UserPreferences $filter): bool
{
$sizeLow = 000;
$sizeHigh = 4096;
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
return true;
}
return false;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Download\Downloader;
use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles;
use App\Download\Framework\Entity\Download;
use Doctrine\ORM\EntityManagerInterface;
@@ -19,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
private readonly Broadcaster $broadcaster,
) {}
/**
@@ -82,6 +84,7 @@ class ProcessDownloader implements DownloaderInterface
});
if ($downloadEntity->getStatus() !== 'Paused') {
$downloadEntity->setProgress(100);
$this->alertComplete($downloadEntity);
}
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
@@ -105,4 +108,15 @@ class ProcessDownloader implements DownloaderInterface
throw new \Exception("There is no download path for media type: $mediaType");
}
private function alertComplete(Download $download): void
{
if ("tvshows" === $download->getMediaType()) {
$message = '"' . $download->getTitle() . '" - ' . $download->getEpisodeId() . ' has finished downloading.';
} else {
$message = '"' . $download->getTitle() . '" has finished downloading.';
}
$this->broadcaster->alert('Success', $message, sendPush: true);
}
}

View File

@@ -6,7 +6,7 @@ use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;

View File

@@ -7,7 +7,7 @@ use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Symfony\Component\Security\Core\User\UserInterface;
/**

View File

@@ -6,7 +6,7 @@ use App\Base\Service\MediaFiles;
use App\Library\Action\Command\LibrarySearchCommand;
use App\Library\Action\Result\LibrarySearchResult;
use App\Library\Dto\MediaFileDto;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

View File

@@ -5,7 +5,6 @@ namespace App\Monitor\Action\Handler;
use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
@@ -44,6 +43,11 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) {
$monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate));
}
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult(

View File

@@ -12,7 +12,7 @@ use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

View File

@@ -11,9 +11,10 @@ use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -47,6 +48,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
&& null !== $episode->season
)
;
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
@@ -95,6 +97,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
->setMonitorType('tvepisode')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setAirDate($episode['air_date'] !== null && $episode['air_date'] !== "" ? Carbon::parse($episode['air_date']) : null)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Monitor\Dto;
use Carbon\Carbon;
class UpcomingEpisode
{
public function __construct(
public string $title,
public string $airDate {
get => Carbon::parse($this->airDate)->format('m/d/Y');
},
public string $episodeTitle,
public int $episodeNumber,
) {}
}

View File

@@ -2,11 +2,13 @@
namespace App\Monitor\Framework\Controller;
use Aimeos\Map;
use App\Base\Service\Broadcaster;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
@@ -65,4 +67,45 @@ class ApiController extends AbstractController
'message' => 'Manually dispatched MonitorDispatcher'
]);
}
#[Route('/api/monitor/upcoming-episodes', name: 'api.monitor.upcoming-episodes', methods: ['GET'])]
public function upcomingEpisodes(MonitorRepository $repository): Response
{
$colors = [
'blue' => '#007bff',
'indigo' => '#6610f2',
'purple' => '#6f42c1',
'pink' => '#e83e8c',
'red' => '#dc3545',
'orange' => '#fd7e14',
'yellow' => '#ffc107',
'green' => '#28a745',
'teal' => '#20c997',
'cyan' => '#17a2b8',
];
$eventColors = [];
$monitors = $repository->whereAirDateNotNull();
$monitors = Map::from($monitors)->map(function ($monitor) use (&$eventColors, $colors) {
if (!array_key_exists($monitor->getImdbId(), $eventColors)) {
$eventColors[$monitor->getImdbId()] = $colors[array_rand($colors)];
}
return [
'id' => $monitor->getId(),
'title' => $monitor->getTitle() . ' (S' . str_pad($monitor->getSeason(), 2, '0', STR_PAD_LEFT) . 'E' . str_pad($monitor->getEpisode(), 2, '0', STR_PAD_LEFT) . ')',
'start' => $monitor->getAirDate()->format('Y-m-d H:i:s'),
'groupId' => $monitor->getImdbId(),
'allDay' => true,
'backgroundColor' => $eventColors[$monitor->getImdbId()],
'borderColor' => $eventColors[$monitor->getImdbId()],
];
});
return $this->json([
'status' => 200,
'data' => [
'episodes' => $monitors->toArray(),
]
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Monitor\Framework\Controller;
use Aimeos\Map;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User;
use Spatie\IcalendarGenerator\Components\Calendar;
use Spatie\IcalendarGenerator\Components\Event;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class CalendarController extends AbstractController
{
#[IsGranted('PUBLIC_ACCESS')]
#[Route('/monitors/ical/{email:user}/upcoming-episodes.ics', name: 'app.monitors.ical')]
public function icalAction(MonitorRepository $monitorRepository, User $user)
{
if (false === $user->hasICalEnabled()) {
return new Response('Calendar not found.', 404);
}
$calendar = Calendar::create()
->name('Upcoming Episodes')
->refreshInterval(10);
$monitors = $monitorRepository->whereAirDateNotNull();
$calendar->event(Map::from($monitors)->map(function ($monitor) {
return new Event($monitor->getTitle())
->startsAt($monitor->getAirDate())
->fullDay();
})->toArray());
return new Response($calendar->get(), 200, [
'Content-Type' => 'text/calendar',
'Content-Disposition' => 'inline; filename="upcoming-episodes.ics"',
]);
}
}

View File

@@ -28,4 +28,10 @@ class WebController extends AbstractController
{
return $this->render('monitor/index.html.twig');
}
#[Route('/monitors/upcoming-episodes', name: 'app.monitor.upcoming-episodes', methods: ['GET'])]
public function upcomingEpisodes()
{
return $this->render('monitor/upcoming-episodes.html.twig');
}
}

View File

@@ -56,6 +56,9 @@ class Monitor
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $airDate = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
@@ -257,6 +260,17 @@ class Monitor
return $this;
}
public function getAirDate(): ?\DateTimeInterface
{
return $this->airDate;
}
public function setAirDate(?\DateTimeInterface $airDate): static
{
$this->airDate = $airDate;
return $this;
}
public function removeChild(self $child): static
{
if ($this->children->removeElement($child)) {

View File

@@ -33,4 +33,12 @@ class MonitorRepository extends ServiceEntityRepository
return $this->paginator->paginate($query, $page, $perPage);
}
public function whereAirDateNotNull()
{
$query = $this->createQueryBuilder('m')
->andWhere('m.airDate IS NOT NULL')
->getQuery();
return $query->getResult();
}
}

View File

@@ -19,7 +19,8 @@ class GetMediaInfoHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
$relatedMedia = $this->tmdb->relatedMedia($media->tmdbId, $command->mediaType);
return new GetMediaInfoResult($media, $command->season, $command->episode);
return new GetMediaInfoResult($media, $relatedMedia, $command->season, $command->episode);
}
}

View File

@@ -10,6 +10,7 @@ class GetMediaInfoResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public array $relatedMedia,
public ?int $season,
public ?int $episode,
) {}

View File

@@ -7,9 +7,6 @@ use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;

View File

@@ -247,6 +247,21 @@ class Tmdb
return $series;
}
public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 6)
{
$repos = [
'movies' => $this->movieRepository,
'tvshows' => $this->tvRepository,
];
$results = $repos[$mediaType]->getRecommendations($tmdbId);
return Map::from(array_values($results->toArray()))
->slice(0, 6)
->map(function ($result) use ($mediaType) {
return $this->parseResult($result, $mediaType);
})->toArray();
}
public function mediaDetails(string $id, string $type)
{
$id = $this->find($id);
@@ -286,13 +301,18 @@ class Tmdb
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['first_air_date']))->format('Y'),
year: $airDate,
mediaType: "tvshows",
episodes: $data['episodes'],
);
@@ -300,6 +320,11 @@ class Tmdb
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['air_date'], ['', null,])) {
$airDate = (new \DateTime($data['air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
@@ -309,12 +334,17 @@ class Tmdb
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
episodeAirDate: (new \DateTime($data['air_date']))->format('m/d/Y'),
episodeAirDate: $airDate,
);
}
private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
@@ -323,7 +353,7 @@ class Tmdb
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: (new \DateTime($data['release_date']))->format('m/d/Y'),
episodeAirDate: $airDate,
);
}

View File

@@ -3,7 +3,7 @@
namespace App\Torrentio\Result;
use App\User\Database\CountryLanguages;
use Nihilarr\PTN;
use App\Base\Util\PTN;
class ResultFactory
{

View File

@@ -3,7 +3,7 @@
namespace App\Twig\Components;
use Aimeos\Map;
use App\Monitor\Dto\UpcomingEpisode;
use App\Monitor\Factory\UpcomingEpisodeDto;
use App\Monitor\Framework\Entity\Monitor;
use App\Tmdb\Tmdb;
use Carbon\CarbonImmutable;
@@ -70,7 +70,7 @@ final class UpcomingEpisodes extends AbstractController
}
return $episodes->map(function (array $episode) use ($monitor) {
return new UpcomingEpisode(
return new UpcomingEpisodeDto(
$monitor->getTitle(),
$episode['air_date'],
$episode['name'],

View File

@@ -80,7 +80,7 @@ class UtilExtension
// Capture season
$seasonMatch = [];
preg_match('/[sS]\d\d/', $episodeId, $seasonMatch);
preg_match('/[sS]\d\d(\d)?(\d)?/', $episodeId, $seasonMatch);
if (empty($seasonMatch)) {
$season = "";
} else {
@@ -89,7 +89,7 @@ class UtilExtension
// Capture episode
$episodeMatch = [];
preg_match('/[eE]\d\d/', $episodeId, $episodeMatch);
preg_match('/[eE]\d\d(\d)?(\d)?/', $episodeId, $episodeMatch);
if (empty($episodeMatch)) {
$episode = "";
} else {

View File

@@ -0,0 +1,13 @@
<?php
namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserCalendarPreferencesCommand implements CommandInterface
{
public function __construct(
public string $enable_ical_up_ep,
) {}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserDownloadPreferencesResult;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserCalendarPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
foreach ($command as $preference => $value) {
if ($user->hasUserPreference($preference)) {
$user->updateUserPreference($preference, $value);
$this->entityManager->flush();
continue;
}
$preference = $this->preferenceRepository->find($preference);
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue($value)
);
}
$this->entityManager->flush();
return new SaveUserDownloadPreferencesResult($user->getDownloadPreferences());
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\User\Action\Input;
use App\User\Action\Command\SaveUserCalendarPreferencesCommand;
use App\User\Action\Command\SaveUserDownloadPreferencesCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<SaveUserDownloadPreferencesInput, SaveUserDownloadPreferencesCommand> */
class SaveUserCalendarPreferencesInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public mixed $userId,
#[SourceRequest('enable_ical_up_ep', nullify: true)]
public bool $enableIcalUpcomingEpisodes,
) {}
public function toCommand(): C
{
return new SaveUserCalendarPreferencesCommand(
$this->enableIcalUpcomingEpisodes,
);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\User\Database;
class CodecList
{
public static $codecs = [
'-',
'h264',
'h265/HEVC',
];
@@ -16,9 +17,13 @@ class CodecList
public static function asSelectOptions(): array
{
return [
'h264' => 'h264',
'h265/HEVC' => 'h265',
];
$result = [];
foreach (static::$codecs as $codec) {
$result[$codec] = $codec;
}
$result['h265/HEVC'] = 'h265';
return $result;
}
}

View File

@@ -26,7 +26,7 @@ class ProviderList
public static function asSelectOptions(): array
{
$result = [];
$result = ['-' => '-'];
foreach (static::$providers as $provider) {
$result[$provider] = $provider;
}

View File

@@ -102,7 +102,7 @@ class QualityList
public static function asSelectOptions(): array
{
$result = ['n/a' => null];
$result = ['n/a' => null, '-' => '-'];
foreach (array_keys(static::$qualities) as $quality) {
$result[$quality] = $quality;
}

View File

@@ -18,7 +18,7 @@ class ResolutionList
public static function asSelectOptions(): array
{
$result = [];
$result = ['-' => '-'];
foreach (static::$resolutions as $resolution) {
$result[$resolution] = $resolution;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\User\Framework\Controller\Api;
use App\User\Dto\UserPreferences;
use App\User\Dto\UserPreferencesFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/user/filters')]
class UserFilterApiController extends AbstractController
{
#[Route('', 'api.user.filters', methods: ['GET'])]
public function getFilters(): Response
{
return $this->json([
UserPreferencesFactory::createFromUser($this->getUser())
]);
}
}

View File

@@ -6,8 +6,10 @@ namespace App\User\Framework\Controller\Web;
use App\Base\Service\Broadcaster;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Handler\SaveUserCalendarPreferencesHandler;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserCalendarPreferencesInput;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Database\CountryLanguages;
@@ -33,6 +35,7 @@ class PreferencesController extends AbstractController
public function mediaPreferences(): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
@@ -40,6 +43,7 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -52,8 +56,8 @@ class PreferencesController extends AbstractController
): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$form = $this->createForm(UserMediaPreferencesForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -67,6 +71,7 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -79,6 +84,7 @@ class PreferencesController extends AbstractController
): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
@@ -93,6 +99,34 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
}
#[Route('/user/preferences/calendar', 'app.save.calendar-preferences', methods: ['POST'])]
public function saveCalendarPreferences(
SaveUserCalendarPreferencesInput $input,
SaveUserCalendarPreferencesHandler $handler,
): Response
{
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: 'Your calendar preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',
[
'downloadPreferences' => $this->getUser()->getDownloadPreferences(),
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);

View File

@@ -327,4 +327,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
}
return [];
}
public function getCalendarPreferences(): array
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->filter(fn(UserPreference $userPreference) => $userPreference->getPreference()->getType() === 'calendar')
->toArray()
;
}
public function hasICalEnabled(): bool
{
return $this->hasUserPreference('enable_ical_up_ep') &&
(bool) $this->getUserPreference('enable_ical_up_ep')->getPreferenceValue() === true;
}
}

View File

@@ -53,9 +53,9 @@ class UserMediaPreferencesForm extends AbstractType
'data-preferred' => \json_encode($this->userPreferences->$fieldName),
],
'row_attr' => [
'class' => 'filter-label'
'class' => 'filter-label text-white'
],
'label_attr' => ['class' => 'text-white block font-semibold mb-2'],
'label_attr' => ['class' => 'block font-semibold mb-2'],
'choices' => $this->addDefaultChoice($choices),
'required' => false,
'multiple' => true,
@@ -68,13 +68,13 @@ class UserMediaPreferencesForm extends AbstractType
$resolver->setDefaults([
'action' => $this->urlGenerator->generate('app_user_media_preferences_submit'),
'attr' => [
'class' => 'filter-items w-full p-4 bg-black/20 border-2 border-orange-500 text-md text-gray-500 dark:text-gray-50 rounded-lg',
'class' => 'filter-items w-full p-4 bg-black/20 border-2 border-orange-500 text-md dark:text-gray-50 rounded-lg',
]
]);
}
private function addDefaultChoice(array $choices): iterable
{
return ['n/a' => ''] + $choices;
return ['n/a' => 'n/a'] + $choices;
}
}

View File

@@ -86,6 +86,21 @@
"phpstan.dist.neon"
]
},
"phpunit/phpunit": {
"version": "12.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
},
"files": [
".env.test",
"phpunit.dist.xml",
"tests/bootstrap.php",
"bin/phpunit"
]
},
"spomky-labs/pwa-bundle": {
"version": "1.2.5"
},
@@ -217,6 +232,27 @@
"config/packages/messenger.yaml"
]
},
"symfony/notifier": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "5.0",
"ref": "178877daf79d2dbd62129dd03612cb1a2cb407cc"
},
"files": [
"config/packages/notifier.yaml"
]
},
"symfony/ntfy-notifier": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "0d5e496659d7361bb4e6648eb8332f8cf097533d"
}
},
"symfony/property-info": {
"version": "7.3",
"recipe": {

View File

@@ -12,6 +12,7 @@
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
{% endblock %}
</head>
<body class="flex flex-col bg-stone-700">
@@ -30,5 +31,6 @@
{% block body %}{% endblock %}
</div>
</div>
<twig:PreviewModal id="previewModal" />
</body>
</html>

View File

@@ -14,10 +14,15 @@
{% if entity.status != "Complete" %}
<turbo-stream action="update" target="download_progress_{{ id }}">
<template>
<div class="background text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
{% if entity.progress >= 50 %}
{% set text_color = "text-black dark:text-black" %}
{% else %}
{% set text_color = "text-black dark:text-white" %}
{% endif %}
<div class="background {{ text_color }} text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width: {{ entity.progress }}%">
</div>
<div class="number text-black font-bold text-center z-40"
<div class="number {{ text_color }} font-bold text-center z-40"
>{{ entity.progress }}%</div>
</template>
</turbo-stream>
@@ -50,7 +55,11 @@
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" />
{% if entity.mediaType == "tvshows" %}
<twig:Alert title="Success" message="{{ entity.title }} - ({{ entity.episodeId }}) has finished downloading." alert_id="{{ entity.id }}" data-controller="alert" />
{% else %}
<twig:Alert title="Success" message="{{ entity.title }} has finished downloading." alert_id="{{ entity.id }}" data-controller="alert" />
{% endif %}
</template>
</turbo-stream>

View File

@@ -6,8 +6,12 @@
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg>
<span class="sr-only">Info</span>
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
<twig:ux:icon name="ic:twotone-cancel" style="text-align:right" width="16.75px" height="16.75px" class="modal-close rounded-full align-end text-red-600 hover:text-red-700" />
<span class="sr-only">Info</span>
</div>
<div class="mt-2 text-sm w-[300px] font-bold overflow-hidden text-wrap">
{{ message }}

View File

@@ -1,13 +1,11 @@
<div{{ attributes }}>
<div class="flex flex-col bg-sky-950 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl
backdrop-filter backdrop-blur-md bg-opacity-40 z-10
">
<div class="flex flex-col bg-sky-950/40 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl">
<div class="p-4 md:p-5">
<h3 class="mb-4 text-lg font-bold text-white">
{{ title }}
</h3>
<div class="{{ contentClass|default('flex flex-col overflow-hidden rounded-md') }}">
<div class="{{ contentClass|default('flex flex-col rounded-md') }}">
{% block content %}{% endblock %}
</div>
</div>

View File

@@ -7,45 +7,45 @@
</div>
{% endif %}
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<table id="downloads" class="divide-y divide-gray-200 dark:divide-gray-800 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<tr class="bg-orange-500/80 text-gray-800 dark:text-stone-800 text-xs font-medium uppercase">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate">
class="px-6 py-3 truncate text-start">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
class="px-6 py-3 truncate text-start {{ isWidget == true ? "hidden" : "r-tablecell" }}">
Filename
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
class="px-6 py-3 truncate text-start {{ isWidget == true ? "hidden" : "r-tablecell" }}">
Media type
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
class="px-6 py-3 text-start">
Progress
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
class="px-6 py-3 text-start">
</th>
</tr>
</thead>
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
<tbody id="{{ table_body_id }}" class="dark:text-white divide-y divide-gray-200 dark:divide-gray-900" data-download-list-target="download">
{% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %}
<twig:DownloadListRow download="{{ download }}" isWidget="{{ isWidget }}" />
{% endfor %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
<tr id="download_view_all">
<td class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black truncate" colspan="100%">
<td class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black" colspan="100%">
<a href="{{ path('app_downloads') }}">View All Downloads</a>
</td>
</tr>
{% endif %}
{% else %}
<tr id="{{ table_body_id }}_no_downloads">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium text-gray-800 dark:text-stone-800" colspan="100%">
<tr id="{{ table_body_id }}_no_downloads" class="text-black dark:text-white dark:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium" colspan="100%">
No downloads
</td>
</tr>

View File

@@ -1,5 +1,18 @@
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
<tr{{ attributes }} is="download-list-row" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900" id="ad_download_{{ download.id }}" data-title="{{ download.title }}"
download-id="{{ download.id }}"
imdb-id="{{ download.imdbId }}"
media-title="{{ download.title }}"
url="{{ download.url }}"
filename="{{ download.filename }}"
status="{{ download.status }}"
progress="{{ download.progress }}"
media-type="{{ download.mediaType }}"
episode-id="{{ download.episodeId }}"
created-at="{{ download.createdAt|date('m/d/Y g:i a') }}"
updated-at="{{ download.updatedAt|date('m/d/Y g:i a') }}"
data-filename="{{ download.filename }}" data-media-type="{{ download.mediaType }}" data-status="{{ download.status }}" data-progress="{{ download.progress }}"
>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium truncate">
{% if download.mediaType == "movies" %}
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType} %}
{% set route = path('app_search_result', routeParams) %}
@@ -18,11 +31,11 @@
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate">
{{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}">
{{ download.mediaType }}
</td>
@@ -33,7 +46,7 @@
<div class="background text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width: {{ download.progress }}%">
</div>
<div class="number text-black font-bold text-center z-40"
<div class="number text-black dark:text-white font-bold text-center z-40"
>{{ download.progress }}%</div>
</div>
</div>

View File

@@ -6,9 +6,11 @@
data-result-filter-tv-episode-list-outlet=".episode-list"
data-action="change->result-filter#filter action-button:downloadSeason@window->result-filter#downloadSeason"
>
{% set preferences_form = form %}
{{ form_start(preferences_form) }}
<div class="flex flex-col md:flex-row gap-2 justify-between">
<h3 class="font-bold text-lg mb-2 md:mb-4">Apply a filter to your results</h3>
<div class="flex flex-col md:flex-row gap-2 justify-between">
{{ form_row(preferences_form.resolution) }}
{{ form_row(preferences_form.codec) }}
{{ form_row(preferences_form.language) }}
@@ -20,7 +22,7 @@
<label for="season">
Season
</label>
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 mb-4 py-1 md:py-2 text-center bg-orange-500 text-white rounded-ms"
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 mb-4 py-1 md:py-2 text-center bg-orange-500 rounded-ms text-black"
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
>

View File

@@ -5,12 +5,17 @@
<twig:SearchBar />
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm">
<ul class="ml-4 flex items-end md:items-center md:gap-6 text-sm">
<li>
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false" title="View upcoming episodes of the shows you're subscribed to.">
<twig:ux:icon name="solar:calendar-linear" width="25px" class="text-orange-500" />
</a>
</li>
<li class="hidden">
<twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/>
</li>
<li class="hidden md:block">
<a href="{{ path('app_logout') }}">
<a href="{{ path('app_logout') }}" title="Log out of Torsearch.">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a>
</li>

View File

@@ -4,55 +4,57 @@
<twig:DownloadSearch search_path="app_search" placeholder="Find {{ type == "complete" ? "a" : "an" }} {{ type }} monitor..." />
</div>
{% endif %}
<table id="monitor_list" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}>
<table id="monitor_list" class="divide-y divide-gray-200 dark:divide-gray-800 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Monitor\\Framework\\Entity\\Monitor') }}
{{ stimulus_target('monitor_list', 'monitorList') }}
>
<thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<tr class="bg-orange-500/80 text-gray-800 dark:text-stone-800 text-xs font-medium uppercase">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase truncate">
class="px-6 py-3 text-start truncate">
Title
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
class="px-6 py-3 text-start">
ID
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="hidden md:table-cell px-6 py-3 text-start">
Search Count
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="hidden md:table-cell px-6 py-3 text-start">
Created at
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="hidden md:table-cell px-6 py-3 text-start">
Last Search Date
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="hidden md:table-cell px-6 py-3 text-start">
Type
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
class="px-6 py-3 text-start">
Status
</th>
<th class="hidden md:table-cell"></th>
</tr>
</thead>
<tbody id="monitors" class="divide-y divide-gray-50">
<tbody id="monitors" class="dark:text-white divide-y divide-gray-200 dark:divide-gray-900">
{% if this.monitors.items|length > 0 %}
{% for monitor in this.monitors.items %}
<twig:MonitorListRow :monitor="monitor" isWidget="{{ this.isWidget }}" />
{% endfor %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black min-w-[50ch] max-w-[50ch] truncate">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black">
<a href="{{ path('app_monitors') }}">View All Monitors</a>
</td>
</tr>
{% endif %}
{% else %}
<tr id="active_monitors_no_monitors">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center col-span-2 font-medium text-stone-800" colspan="100%">
<td class="px-6 py-4 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900 text-center text-xs uppercase font-medium" colspan="100%">
No monitors
</td>
</tr>

View File

@@ -1,4 +1,17 @@
<tr{{ attributes }} id="monitor_{{ monitor.id }}" class="hover:bg-gray-200">
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900"
monitor-id="{{ monitor.id }}"
imdb-id="{{ monitor.imdbId }}"
media-title="{{ monitor.title }}"
season="{{ monitor.season }}"
episode="{{ monitor.episode }}"
status="{{ monitor.status }}"
search-count="{{ monitor.searchCount }}"
media-type="{{ monitor.monitorType|monitor_type }}"
episode-id="{{ monitor|monitor_media_id }}"
created-at="{{ monitor.createdAt|date('m/d/Y g:i a') }}"
last-search="{{ monitor.lastSearch|date('m/d/Y g:i a') }}"
downloaded-at="{{ monitor.downloadedAt|date('m/d/Y g:i a') }}"
>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
class="mr-1 hover:underline rounded-md"
@@ -13,24 +26,24 @@
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
{% endif %}
<a href="{{ route }}"
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate dark:text-white">
{{ monitor.title }}
</a>
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ monitor|monitor_media_id }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.searchCount }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
{% if monitor.monitorType == "tvshow" %}
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% elseif monitor.monitorType == "tvseason" %}
@@ -39,7 +52,7 @@
<twig:StatusBadge color="fuchsia" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}
@@ -59,11 +72,11 @@
{{ monitor.title }}
</td>
{% if monitor|monitor_media_id != "-" %}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ monitor|monitor_media_id }}
</td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if monitor.monitorType == "tvshow" %}
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% elseif monitor.monitorType == "tvseason" %}
@@ -72,7 +85,7 @@
<twig:StatusBadge color="fuchsia" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if monitor.status == "New" %}
<twig:StatusBadge color="orange" status="{{ monitor.status }}" />
{% elseif monitor.status == "In Progress" or monitor.status == "Active" %}

View File

@@ -0,0 +1,10 @@
<dialog{{ attributes }} is="preview-content-dialog" class="py-3 px-4 w-full md:w-[50rem] rounded-md dark:bg-gray-950/80 dark:border-2 dark:border-orange-500 dark:text-white backdrop-filter backdrop-blur-3xl">
<div class="flex flex-row justify-end">
<twig:ux:icon name="ic:twotone-cancel" width="16.75px" height="16.75px" class="modal-close rounded-full align-middle text-red-600 hover:text-red-700" />
</div>
<h2 class="modal-heading mb-4 text-2xl font-bold text-orange-500">{{ heading|default('') }}</h2>
<div class="modal-content mb-4">
{% block content %}{% endblock %}
</div>
</dialog>

View File

@@ -16,7 +16,7 @@
active: 'true',
}) }}
>
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500/60 bg-clip-padding backdrop-filter backdrop-blur-md rounded-md">
<div class="flex flex-col md:flex-row gap-4">
{% if episode['poster'] != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />

View File

@@ -0,0 +1,9 @@
{% extends 'base.html.twig' %}
{% block h2 %}Test Test{% endblock %}
{% block body %}
<div>
<!-- Well what are you doing here? -->
</div>
{% endblock %}

View File

@@ -4,6 +4,10 @@
{% block h2 %}Monitors{% endblock %}
{% block action_buttons %}
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false"
class="h-6 bg-orange-500/80 hover:bg-orange-600/80 px-2 text-white rounded-ms text-sm font-semibold">
Upcoming Episodes
</a>
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends 'base.html.twig' %}
{% block h2 %}Upcoming Episodes{% endblock %}
{% block action_buttons %}
<a href="{{ path('app.monitor.upcoming-episodes') }}" data-turbo="false"
class="h-6 bg-orange-500/80 hover:bg-orange-600/80 px-2 text-white rounded-ms text-sm font-semibold">
Upcoming Episodes
</a>
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
{% endblock %}
{% block body %}
{{ turbo_page_requires_reload() }}
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
<div class="p-4">
<twig:Card title="Upcoming episodes of shows your monitoring">
<a href="{{ path('app.monitors.ical', {email: app.user.email}) }}" title="Subscribe to the 'Upcoming Episodes' calendar via iCal. Click to export the events to a .ics file or copy the link and use it to subscribe in a calendar app that supports iCal/ics calendars." class="mb-2 self-end dark:text-white decoration-underline">
<twig:ux:icon name="lets-icons:calendar-add-light" width="24" class="text-orange-500" />
</a>
<div id="calendar" class="text-white">
</div>
</twig:Card>
</div>
<script>
function getView() {
if (window.innerWidth < 768) {
return 'listWeek';
} else {
return 'dayGridMonth';
}
}
document.addEventListener('DOMContentLoaded', async function() {
let data = await fetch('/api/monitor/upcoming-episodes');
data = (await data.json())['data'];
const calendarEl = document.getElementById('calendar');
const calendar = new FullCalendar.Calendar(calendarEl, {
initialView: getView(),
events: data['episodes'],
windowResize: function(arg) {
this.changeView(getView());
}
});
calendar.render();
});
</script>
{% endblock %}

View File

@@ -2,9 +2,10 @@
{% block title %}{{ results.media.title }} &mdash; Download Options &mdash; Torsearch{% endblock %}
{% block h2 %}Media Results{% endblock %}
{% block body %}
<div class="p-4 flex flex-col grow gap-4">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2>
<div class="flex flex-row w-full gap-2">
<twig:Card title="" class="w-full" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
@@ -19,7 +20,7 @@
<div class="w-full flex flex-col">
<div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} ({{ results.media.year }})
{{ results.media.title }} ({{ results.media.year|date('Y') }})
</h3>
{% if results.media.mediaType == "tvshows" %}
@@ -95,12 +96,35 @@
{% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList
results="results"
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :episodeNumber="results.episode"
loading="defer"
:imdbId="results.media.imdbId"
:season="results.season"
:perPage="20"
:pageNumber="1"
:tmdbId="results.media.tmdbId"
:title="results.media.title"
:episodeNumber="results.episode"
/>
{% endif %}
</twig:Card>
</div>
<twig:Card title="Related Media" contentClass="flex flex-col gap-4 text-white">
<p>Results similar to "{{ results.media.title }}" that you may be interested in.</p>
<div class="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
{% for media in results.relatedMedia %}
<twig:Poster imdbId="{{ media.imdbId }}"
tmdbId="{{ media.tmdbId }}"
title="{{ media.title }}"
description="{{ media.description }}"
image="{{ media.poster }}"
year="{{ media.year }}"
mediaType="{{ media.mediaType }}"
/>
{% endfor %}
</div>
</twig:Card>
</div>
<style>
html,

View File

@@ -36,4 +36,25 @@
</form>
</twig:Card>
</div>
<div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Calendar Preferences" class="w-full">
<p class="text-gray-50 mb-4">Manage your Upcoming Episodes calendar.</p>
<form id="calendar_preferences" class="flex flex-col" name="calendar_preferences" method="post" action="{{ path('app.save.calendar-preferences') }}">
<div class="flex flex-col gap-2">
<div class="flex flex-row gap-2 mb-1">
<input type="hidden" name="enable_ical_up_ep" id="enable_ical_up_ep_hidden" value="0" />
<input type="checkbox" name="enable_ical_up_ep" id="enable_ical_up_ep" value="1" {{ calendarPreferences['enable_ical_up_ep'].getPreferenceValue() == true ? 'checked' }} />
<label class="text-gray-50" for="enable_ical_up_ep">Enable a publicly available iCal calendar?</label>
</div>
<small class="text-gray-50 mb-4">Enabling the iCal calendar will allow you to subscribe from iCal
supporting clients. This endpoint will be publicly available with no authentication required.
Disabling this option will disable the calendar and public endpoint for your user.
This will not affect the calendar within the app.
</small>
</div>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Tests\Download;
use PHPUnit\Framework\TestCase;
class DownloadOptionEvaluatorTest extends TestCase
{
public function testEpisodeExists(): void
{
$this->assertTrue(true);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Tests\Monitor;
use App\Base\Service\MediaFiles;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvEpisodeHandler;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
class MonitorTvShowHandlerTest extends TestCase
{
private MonitorTvShowHandler $handler;
private MonitorRepository $monitorRepository;
private EntityManagerInterface $entityManager;
private MonitorTvEpisodeHandler $episodeHandler;
private MediaFiles $mediaFiles;
private LoggerInterface $logger;
private Tmdb $tmdb;
protected function setUp(): void
{
$this->monitorRepository = $this->createMock(MonitorRepository::class);
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->episodeHandler = $this->createMock(MonitorTvEpisodeHandler::class);
$this->mediaFiles = $this->createMock(MediaFiles::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->tmdb = $this->createMock(Tmdb::class);
$this->handler = new MonitorTvShowHandler(
$this->monitorRepository,
$this->entityManager,
$this->episodeHandler,
$this->mediaFiles,
$this->logger,
$this->tmdb
);
}
public function testEpisodeExists(): void
{
// Arrange
$monitor = $this->createMock(Monitor::class);
$monitor->method('getId')->willReturn(1);
$monitor->method('getTmdbId')->willReturn('63770');
$monitor->method('getSeason')->willReturn(10);
$monitor->method('getTitle')->willReturn('The Late Show with Stephen Colbert');
$this->monitorRepository->expects($this->once())
->method('find')
->with(1)
->willReturn($monitor);
$this->tmdb->expects($this->once())
->method('seasonDetails')
->with(63770, 10)
->willReturn((object)['episodes' => [$this->getTmdbEpisode()]]);
$this->mediaFiles->expects($this->once())
->method('findEpisodes')
->willReturn($this->getDownloadedEpisodes());
// Act
$command = new MonitorTvShowCommand(1);
$result = $this->handler->handle($command);
// Assert
$this->assertInstanceOf(MonitorTvShowResult::class, $result);
$this->assertEquals('OK', $result->status);
$this->assertTrue(isset($result->result['monitor']));
$this->assertSame($monitor, $result->result['monitor']);
}
public function testEpisodeDoesNotExist(): void
{
// Arrange
$monitor = $this->createMock(Monitor::class);
$monitor->method('getId')->willReturn(1);
$monitor->method('getTmdbId')->willReturn(63770);
$monitor->method('getSeason')->willReturn(10);
$monitor->method('getTitle')->willReturn('The Late Show with Stephen Colbert');
$this->monitorRepository->expects($this->once())
->method('find')
->with(1)
->willReturn($monitor);
$this->tmdb->expects($this->once())
->method('seasonDetails')
->with(63770, 10)
->willReturn((object)['episodes' => []]);
$this->mediaFiles->expects($this->once())
->method('findEpisodes')
->willReturn([]);
// Act
$command = new MonitorTvShowCommand(1);
$result = $this->handler->handle($command);
// Assert
$this->assertInstanceOf(MonitorTvShowResult::class, $result);
$this->assertEquals('OK', $result->status);
$this->assertTrue(isset($result->result['monitor']));
$this->assertSame($monitor, $result->result['monitor']);
}
public function testHandleWithInvalidMonitorId(): void
{
// Arrange
$this->monitorRepository->expects($this->once())
->method('find')
->with(999)
->willReturn(null);
// Act & Assert
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Monitor not found');
$command = new MonitorTvShowCommand(999);
$this->handler->handle($command);
}
private function getTmdbEpisode(): array
{
return [
"air_date" => "2016-05-13",
"episode_number" => 142,
"episode_type" => "standard",
"id" => 1192242,
"name" => "Matt Bomer, Zach Woods, Nick Griffin",
"overview" => "",
"production_code" => "",
"runtime" => 45,
"season_number" => 1,
"show_id" => 63770,
"still_path" => null,
"vote_average" => 0.0,
"vote_count" => 0,
"crew" => [],
"guest_stars" => [],
"poster" => null,
];
}
private function getDownloadedEpisodes(): array
{
$episode1 = (object)[
"season" => 10,
"episode" => 142,
"resolution" => "1080p",
"codec" => "h264",
"group" => "jebaited.mkv",
"container" => "mkv",
"title" => "42 stephen colbert 2025 07 21 sandra oh",
"episodeName" => "E1 web",
];
$episode2 = (object)[
'season' => 10,
'episode' => 142,
'resolution' => '1080p',
'codec' => 'x265',
'group' => 'MeGusta[EZTVx.to].mkv',
'container' => 'mkv',
'title' => '42 Stephen Colbert 2025 07 21 Sandra Oh',
'episodeName' => 'E1 HEVC'
];
return [$episode1, $episode2];
}
}

13
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php';
if (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
if ($_SERVER['APP_DEBUG']) {
umask(0000);
}