Compare commits

..

85 Commits

Author SHA1 Message Date
9a1847a2c3 fix: allows normal search alongside autocomplete 2025-07-06 12:41:56 -05:00
17f6316353 fix: better styles for active option 2025-07-06 12:19:37 -05:00
cc366eb09f fix: moves tmdb search under tmdb namespace 2025-07-06 11:07:11 -05:00
b0425f7085 fix: styles results, updates loader 2025-07-06 10:05:11 -05:00
023b1b7844 fix: redirects user on selection 2025-07-06 09:31:47 -05:00
eafcf3fcb1 wip: renders live search results 2025-07-06 09:07:51 -05:00
25f803d1dd fix: styles 2025-07-05 14:43:39 -05:00
98041fd20b fix: result filter not filtering 2025-07-05 13:58:00 -05:00
d29b84ec78 fix: better logging for monitor cleanup 2025-07-04 20:54:49 -05:00
ccce0303c3 fix: better logging for monitor cleanup 2025-07-04 15:53:13 -05:00
9eaa120257 fix: stuck monitors 2025-07-04 15:15:09 -05:00
d6cbb53da6 feat: adds torrentio api endpoint 2025-07-04 14:57:39 -05:00
bd47107399 fix: uses parent imdb id if episode id doesn't exist 2025-07-02 16:10:53 -05:00
ac97fdd08f fix: adds r-tablecell class 2025-07-01 23:03:15 -05:00
727c11e1c6 fix: makes user preferences page responsive 2025-06-30 09:16:33 -05:00
be65e2d4e2 fix: makes download list & monitor list responsive 2025-06-30 09:13:49 -05:00
497a3a74cd fix: basic hamburger menu button 2025-06-29 23:19:16 -05:00
101460cd47 fix: mobile results formatting 2025-06-29 22:26:01 -05:00
591c9cdd2a wip: mobile template 2025-06-29 16:01:56 -05:00
2dc53c5270 feat: tags torrentio cache 2025-06-22 22:08:08 -05:00
5938d33c89 fix: alert topic id not being set after getting started controller 2025-06-22 16:40:43 -05:00
d95ab85415 fix: udpates example compose 2025-06-22 16:21:04 -05:00
cc39e46bfd fix: broken delete monitor 2025-06-22 15:50:09 -05:00
ba092ab3c2 chore: remove line 2025-06-22 15:12:37 -05:00
774d6f4999 fix: caches tmdb data 2025-06-22 14:06:01 -05:00
0b56ee937d fix: removes upcoming episodes component from monitors page 2025-06-22 10:08:23 -05:00
7b3d57b94a fix: prepends episode/season number to filename if doesn't exist. should fix monitors repeatedly downloading episodes 2025-06-21 23:26:55 -05:00
be7b610111 fix: monitor tv -> set monitor create date time to 00:00.00 for comparison to air date 2025-06-20 11:46:11 -05:00
3e93a7c9c1 fix: un-hardcodes version 2025-06-20 11:29:50 -05:00
bc78b83f8d fix: includes version number in worker & scheduler 2025-06-20 11:06:34 -05:00
c5bcaeb1d4 fix: shows version in nav bar 2025-06-20 11:05:44 -05:00
e39182ba91 fix: defaults episode count to '-' as an indicator to whether the fetch call has run or not. if there are 0 results after the fetch call, the '-' is updated to '0' 2025-06-20 08:37:35 -05:00
965b747594 fix: monitor checks if episode was released after monitor created 2025-06-20 08:11:27 -05:00
937e3c6270 fix: updates monitor to search for episodes that were released after monitor created 2025-06-20 07:34:18 -05:00
2bb2845ead fix: torrentio for movies 2025-06-19 23:32:07 -05:00
fca189648b fix: adds warning for torrentio rate limit 2025-06-19 23:08:08 -05:00
2121466322 wip: gracefully handles torrentio 429 2025-06-19 22:39:41 -05:00
1e130c3490 fix: stores sessions in redis 2025-06-19 19:50:02 -05:00
4b97faeadb fix: error querying tmdb 2025-06-19 19:30:28 -05:00
3701e31ee0 fix: sets default twig date format as m/d/Y 2025-06-19 16:44:20 -05:00
210c674f25 fix: bad date format 2025-06-19 16:41:42 -05:00
175f4330f1 fix: returns episode data on first page load 2025-06-19 16:23:39 -05:00
12aaf8e737 fix: animates episode toggle list button 2025-06-19 14:49:58 -05:00
2e468dd9b0 fix: cleans up paginator 2025-06-19 14:30:26 -05:00
e070b95a36 wip: working episode pagination, season switcher, monitor only new content 2025-06-19 13:30:22 -05:00
20d397589a fix: undoes num_threads 2025-06-14 15:14:53 -05:00
6c7a35005e fix: sets num_threads=10 2025-06-13 23:44:49 -05:00
0f16423f66 feat: upcoming episodes component 2025-06-12 23:39:06 -05:00
937313fe59 fix: links to series from monitor list row 2025-06-12 19:57:39 -05:00
9b3506ab17 fix: adds hover style on monitor list row 2025-06-12 10:48:23 -05:00
6e0eed8b4e fix: adds default timezone, supports TZ environment variable for changing TZ, renders dates based on TZ 2025-06-12 10:45:28 -05:00
38a5baa17e fix: sets tv show/season monitor status to Active after executing 2025-06-11 20:06:49 -05:00
1d573c09e7 fix: media files -> episodeExists() 2025-06-11 19:40:06 -05:00
7989e2abd2 fix: prevents show & season monitors from completing, forcing them to keep checking for new episodes until removed 2025-06-11 13:06:21 -05:00
df6c68aa46 fix: alert z-index, hover effects on download list row 2025-06-11 12:46:03 -05:00
6cd9a9b18e fix: refactors tv season monitor 2025-06-11 10:30:53 -05:00
b95e8f3794 fix: tvshow monitor 2025-06-11 10:24:58 -05:00
8cc81fea19 wip: working tv season & episode monitors 2025-06-10 21:19:13 -05:00
15648e711b fix: rounded-ms class on download search button 2025-06-10 15:30:41 -05:00
f855aabd69 fix: copy update 2025-06-10 15:17:07 -05:00
55a866170e fix: copy update 2025-06-10 15:15:40 -05:00
9ab4f6cf57 fix: adds timestamps to download entity 2025-06-10 15:10:00 -05:00
d40a4764eb fix: adds loading indicator to download search component 2025-06-10 14:26:37 -05:00
0119830ea7 fix: adds hover styles to download action buttons 2025-06-10 13:45:17 -05:00
7ffa927d55 fix: missing session item for mercure alert topic 2025-06-10 13:39:19 -05:00
0b80779975 fix: entrypoint 2025-06-09 21:39:16 -05:00
3c2092095f feat: pauses & resumes downloads 2025-06-09 16:42:30 -05:00
a7bedae3db wip: pauses downloads 2025-06-09 11:26:17 -05:00
51c2a1c577 fix: checks that season & episode properties exist on PTN 2025-06-09 10:53:41 -05:00
f5ed464719 feat: dedicated queue for warming tv options cache 2025-06-08 17:06:58 -05:00
aa31701ac8 feat: speedbump for deleting monitors 2025-06-08 15:46:34 -05:00
781e4dcd23 feat: speedbump before deleting downloads 2025-06-08 15:25:18 -05:00
b5cd240fbd fix: keyboard navigation on tv results 2025-06-08 10:39:46 -05:00
ce5bc525dd feat: adds search to monitors, adds properties to search against on downloads 2025-06-08 08:58:27 -05:00
63850e48fd feat: shows existing files for already downloaded media 2025-06-07 22:19:05 -05:00
f9a284cb67 wip: displays file info for existing tv episodes 2025-06-07 21:36:33 -05:00
228d320edc feat: tv episode - existance indicator badge 2025-06-07 19:19:12 -05:00
6858d2d722 fix: cleans up download list search bar 2025-06-07 15:31:22 -05:00
8cd004db4a fix: hides search bar when download list is widget 2025-06-07 14:43:38 -05:00
6cc8985c4d feat: adds search to download list component 2025-06-07 14:25:56 -05:00
9bfd92a011 feat: links to movies/series download results page from download list component 2025-06-07 13:18:20 -05:00
ac6276f444 fix: links to media by imdb id 2025-06-07 10:13:40 -05:00
6d4bbf2e72 fix: shows season/episode in download list 2025-06-07 09:14:47 -05:00
8004de1dc8 fix: undoes last change to Caddyfile 2025-06-06 14:20:46 -05:00
2d9a2a1f14 fix: sets num_threads=20 2025-06-05 23:36:17 -05:00
125 changed files with 3759 additions and 524 deletions

View File

@@ -1,8 +1,9 @@
FROM dunglas/frankenphp:php8.4-alpine FROM dunglas/frankenphp:php8.4
ENV SERVER_NAME=":80" ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime" ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1"
RUN install-php-extensions \ RUN install-php-extensions \
pdo_mysql \ pdo_mysql \
@@ -11,8 +12,8 @@ RUN install-php-extensions \
zip \ zip \
opcache opcache
RUN apk add --no-cache wget RUN apt update && apt install -y wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ] HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile

7
assets/bootstrap.js vendored
View File

@@ -1,5 +1,10 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'
import Dialog from '@stimulus-components/dialog'
import Dropdown from '@stimulus-components/dropdown'
const app = startStimulusApp(); const app = startStimulusApp();
// register any custom, 3rd party controllers here // register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController); app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);

View File

@@ -1,5 +1,16 @@
{ {
"controllers": { "controllers": {
"@symfony/ux-autocomplete": {
"autocomplete": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"tom-select/dist/css/tom-select.default.css": true,
"tom-select/dist/css/tom-select.bootstrap4.css": false,
"tom-select/dist/css/tom-select.bootstrap5.css": false
}
}
},
"@symfony/ux-live-component": { "@symfony/ux-live-component": {
"live": { "live": {
"enabled": true, "enabled": true,

View File

@@ -12,6 +12,7 @@ export default class extends Controller {
filename: String, filename: String,
mediaType: String, mediaType: String,
imdbId: String, imdbId: String,
episodeId: String
} }
download() { download() {
@@ -27,6 +28,7 @@ export default class extends Controller {
filename: this.filenameValue, filename: this.filenameValue,
mediaType: this.mediaTypeValue, mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue, imdbId: this.imdbIdValue,
episodeId: this.episodeIdValue
}) })
}) })
.then(res => res.json()) .then(res => res.json())

View File

@@ -7,13 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
static targets = ['download', 'downloadRow', 'viewAllBtn'] static targets = ['download']
static values = {
isWidget: Boolean,
perPage: Number,
}
component = null;
async initialize() { async initialize() {
this.component = await getComponent(this.element); this.component = await getComponent(this.element);
@@ -28,42 +22,25 @@ export default class extends Controller {
// this.fooTarget.addEventListener('click', this._fooBar) // this.fooTarget.addEventListener('click', this._fooBar)
} }
downloadRowTargetConnected(target) {
if (Boolean(target.getAttribute('isBroadcasted')) === true) {
this.viewAllBtnTarget.parentElement.append(this.viewAllBtnTarget);
if (this.downloadRowTargets.length > this.perPageValue) {
target.classList.add('hidden');
this.viewAllBtnTarget.classList.remove('hidden');
} else {
this.viewAllBtnTarget.classList.add('hidden');
}
}
}
downloadRowTargetDisconnected(target) {
this.viewAllBtnTarget.parentElement.append(this.viewAllBtnTarget);
let i = 1;
this.downloadRowTargets.forEach((downloadRow) => {
console.log(downloadRow)
if (i <= this.perPageValue) {
downloadRow.classList.remove('hidden');
} else {
downloadRow.classList.add('hidden');
}
})
if (this.downloadRowTargets.length > this.perPage) {
this.viewAllBtnTarget.classList.remove('hidden');
}
}
downloadTargetConnected(target) { downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr'); let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > this.perPageValue) { if (downloads.length > 5) {
target.classList.add('hidden'); target.classList.add('hidden');
} }
} }
pauseDownload(data) {
fetch(`/api/download/${data.params.id}/pause`, {method: 'PATCH'})
.then(res => res.json())
.then(json => console.debug(json));
}
resumeDownload(data) {
fetch(`/api/download/${data.params.id}/resume`, {method: 'PATCH'})
.then(res => res.json())
.then(json => console.debug(json));
}
deleteDownload(data) { deleteDownload(data) {
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'}) fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json()) .then(res => res.json())

View File

@@ -0,0 +1,48 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static outlets = ['navbar']
initialize() {
// Called once when the controller is first instantiated (per element)
// Here you can initialize variables, create scoped callables for event
// listeners, instantiate external libraries, etc.
// this._fooBar = this.fooBar.bind(this)
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// 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)
}
navbarOutletConnected(outlet) {
console.log(outlet)
}
toggleMenu() {
console.log(this.navbarOutlet);
this.navbarOutlet.toggle();
}
// 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

@@ -56,6 +56,7 @@ export default class extends Controller {
} }
let include = true; let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden'); option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false; option.querySelector('input[type="checkbox"]').checked = false;
@@ -81,6 +82,7 @@ export default class extends Controller {
} }
if (false === include) { if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden'); option.classList.add('hidden');
} else if (true === firstIncluded) { } else if (true === firstIncluded) {
count = 1; count = 1;

View File

@@ -17,9 +17,15 @@ export default class extends Controller {
link.className = this.activeStyles; link.className = this.activeStyles;
} }
}); });
window.addEventListener("resize", (event) => {
});
} }
setActive() { toggle() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
} }
} }

View File

@@ -20,7 +20,7 @@ export default class extends Controller {
"provider": "", "provider": "",
} }
static outlets = ['movie-results', 'tv-results'] static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
static values = { static values = {
'media-type': String, 'media-type': String,
@@ -127,6 +127,10 @@ export default class extends Controller {
} }
} }
setSeason(event) {
this.tvEpisodeListOutlet.setSeason(event.target.value);
}
uncheckSelectAllBtn() { uncheckSelectAllBtn() {
this.selectAllTarget.checked = false; this.selectAllTarget.checked = false;
} }

View File

@@ -0,0 +1,59 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
initialize() {
this._onPreConnect = this._onPreConnect.bind(this);
this._onConnect = this._onConnect.bind(this);
}
connect() {
document.querySelector("#search").onsubmit = (event) => {
event.preventDefault();
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastValue}`
}
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
this.element.addEventListener('autocomplete:connect', this._onConnect);
}
disconnect() {
// You should always remove listeners when the controller is disconnected to avoid side-effects
this.element.removeEventListener('autocomplete:connect', this._onConnect);
this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect);
}
_onPreConnect(event) {
// TomSelect has not been initialized - options can be changed
// console.log(event.detail); // Options that will be used to initialize TomSelect
event.detail.options.onItemAdd = (value, $item) => {
const params = value.split('|')
window.location.href = `/result/${params[0]}/${params[1]}`
};
event.detail.options.render.loading = (data, escape) => {
return `
<span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon">
<svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg>
</span>
`;
}
event.detail.options.render.option = (data, escape) => {
if (data.data.description.length > 60) {
data.data.description = data.data.description.substring(0, 107) + "...";
}
return `<div class="flex flex-row">
<img src="${data.data.poster}" class="w-16 rounded-md">
<div class="p-2 flex flex-col">
<h2>${data.data.title}</h2>
<p class="max-w-[60ch] text-wrap">${data.data.description}</p>
</div>
</div>
`
}
}
_onConnect(event) {
// TomSelect has just been initialized and you can access details from the event
// console.log(event.detail.tomSelect); // TomSelect instance
// console.log(event.detail.options); // Options used to initialize TomSelect
}
}

View File

@@ -0,0 +1,51 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
async initialize() {
this.component = await getComponent(this.element);
this.component.on('render:finished', (component) => {
console.log(component);
});
}
setSeason(season) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('pageNumber', 1);
this.component.set('season', parseInt(season));
this.component.render();
}
paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.action('paginate', {page: event.params.page});
this.component.render();
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// 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)
}
// 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

@@ -18,32 +18,43 @@ export default class extends Controller {
active: Boolean, active: Boolean,
}; };
static targets = ['list', 'count', 'episodeSelector'] static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer']
static outlets = ['loading-icon'] static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false optionsLoaded = false
isOpen = false
async connect() { async connect() {
await this.setOptions(); await this.setOptions();
} }
async setOptions() { async setOptions() {
if (true === this.activeValue && this.optionsLoaded === false) { if (this.optionsLoaded === false) {
this.optionsLoaded = true; this.optionsLoaded = true;
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`) let response;
.then(res => res.text())
.then(response => { try {
this.element.innerHTML = response; response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
this.options = this.element.querySelectorAll('tbody tr'); } catch (error) {
if (this.options.length > 0) { console.log('There was an error', error);
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); }
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else { if (response?.ok) {
this.episodeSelectorTarget.disabled = true; response = await response.text()
} this.listContainerTarget.innerHTML = response;
this.loadingIconOutlet.increaseCount(); this.options = this.element.querySelectorAll('tbody tr');
}); if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
} }
} }
@@ -55,19 +66,13 @@ export default class extends Controller {
// } // }
async setActive() { async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');
if (false === this.optionsLoaded) { if (false === this.optionsLoaded) {
await this.setOptions(); await this.setOptions();
} }
} }
async setInActive() { async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false; this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
} }
isActive() { isActive() {
@@ -85,7 +90,10 @@ export default class extends Controller {
} }
toggleList() { toggleList() {
this.listTarget.classList.toggle('options-table');
this.listTarget.classList.toggle('hidden'); this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90');
} }
download() { download() {
@@ -127,6 +135,7 @@ export default class extends Controller {
} }
let include = true; let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden'); option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false; option.querySelector('input[type="checkbox"]').checked = false;
@@ -152,6 +161,7 @@ export default class extends Controller {
} }
if (false === include) { if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden'); option.classList.add('hidden');
} else if (true === firstIncluded) { } else if (true === firstIncluded) {
count = 1; count = 1;

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPauseOne0"><g fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path fill="#555" d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path stroke-linecap="round" d="M19 18v12m10-12v12"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPauseOne0)"/></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPlay0"><g fill="#555" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path d="M20 24v-6.928l6 3.464L32 24l-6 3.464l-6 3.464z"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPlay0)"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" fill-opacity="0" stroke="currentColor" stroke-dasharray="64" stroke-dashoffset="64" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12c0 -4.97 4.03 -9 9 -9c4.97 0 9 4.03 9 9c0 4.97 -4.03 9 -9 9c-4.97 0 -9 -4.03 -9 -9Z"><animate fill="freeze" attributeName="fill-opacity" begin="0.6s" dur="0.15s" values="0;0.3"/><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0"/></path></svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 0 0"><path fill="currentColor" fill-rule="evenodd" d="M0 3.75A.75.75 0 0 1 .75 3h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 3.75M0 8a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8m.75 3.5a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -10,4 +10,115 @@
h2 { h2 {
font-size: var(--text-xl); font-size: var(--text-xl);
} }
.rounded-ms {
border-radius: 0.275rem;
}
}
@layer components {
.alert {
@apply text-white text-sm min-w-[250px] border px-4 py-3 rounded-md
}
.alert-success {
@apply bg-green-950 hover:bg-green-900 border-green-500
}
.alert-warning {
@apply bg-yellow-500/70 hover:bg-yellow-600 border-yellow-400 text-black
}
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
}
/* Customize the dialog backdrop */
dialog {
box-shadow: 0 0 0 100vw rgb(0 0 0 / 0.5);
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;
}
dialog[data-dialog-target="dialog"][closing] {
animation: fade-out 200ms forwards;
}
.r-tablecell {
display: none;
}
.r-tablerow {
display: flex;
}
@media screen and (min-width: 768px) {
.r-tablecell {
display: inline-table;
}
.r-tablerow {
display: table-row;
}
}
.options-table {
display: flex;
:last-child {
border-bottom: none;
}
}
@media screen and (min-width: 768px) {
.options-table {
display: inline-table;
}
}
#search .ts-wrapper.single .ts-control::after {
display: none !important;
}
#search .ts-control {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
padding-left: 0;
input {
color: #fff !important;
padding: 0;
}
}
#search .ts-dropdown {
background: unset;
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
#search .ts-dropdown .ts-dropdown-content .option.active {
background: unset;
@apply bg-orange-500/80 text-black font-bold rounded-md
} }

View File

@@ -18,10 +18,12 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
- $PWD/var/download:/var/download
- mercure_data:/data - mercure_data:/data
- mercure_config:/config - mercure_config:/config
tty: true tty: true
environment: environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on: depends_on:
@@ -34,8 +36,11 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
- $PWD/var/download:/var/download
tty: true tty: true
command: php /app/bin/console messenger:consume async -vv --time-limit=3600 --limit=10 environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume media_cache -vv --time-limit=3600
scheduler: scheduler:
@@ -43,6 +48,8 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume scheduler_monitor -vv command: php /app/bin/console messenger:consume scheduler_monitor -vv
tty: true tty: true
@@ -53,6 +60,8 @@ services:
- redis_data:/data - redis_data:/data
command: redis-server --maxmemory 512MB command: redis-server --maxmemory 512MB
restart: unless-stopped restart: unless-stopped
environment:
TZ: America/Chicago
database: database:
@@ -62,6 +71,7 @@ services:
volumes: volumes:
- mysql:/var/lib/mysql - mysql:/var/lib/mysql
environment: environment:
TZ: America/Chicago
MYSQL_DATABASE: app MYSQL_DATABASE: app
MYSQL_USERNAME: app MYSQL_USERNAME: app
MYSQL_PASSWORD: password MYSQL_PASSWORD: password

View File

@@ -4,25 +4,29 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.2", "php": ">=8.4",
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"1tomany/rich-bundle": "^1.8", "1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12", "aimeos/map": "^3.12",
"chrisullyott/php-filesize": "^4.2",
"doctrine/dbal": "^3", "doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14", "doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-fixtures-bundle": "^4.1", "doctrine/doctrine-fixtures-bundle": "^4.1",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
"guzzlehttp/guzzle": "^7.9",
"league/pipeline": "^1.1", "league/pipeline": "^1.1",
"nesbot/carbon": "^3.9", "nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1", "nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*", "nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2", "p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1", "php-tmdb/api": "^4.1",
"predis/predis": "^2.4", "predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*", "symfony/asset": "7.3.*",
"symfony/console": "7.3.*", "symfony/console": "7.3.*",
"symfony/doctrine-messenger": "7.3.*", "symfony/doctrine-messenger": "7.3.*",
@@ -40,6 +44,7 @@
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.3.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27",
"symfony/ux-icons": "^2.24", "symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24", "symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24", "symfony/ux-turbo": "^2.24",
@@ -55,6 +60,9 @@
"symfony/flex": true, "symfony/flex": true,
"symfony/runtime": true "symfony/runtime": true
}, },
"platform": {
"php": "8.4"
},
"bump-after-update": true, "bump-after-update": true,
"sort-packages": true "sort-packages": true
}, },

1289
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,4 +18,6 @@ return [
Symfony\UX\Turbo\TurboBundle::class => ['all' => true], Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
]; ];

View File

@@ -11,7 +11,7 @@ framework:
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ] trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session: session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler handler_id: '%env(REDIS_HOST)%'
#esi: true #esi: true
#fragments: true #fragments: true

View File

@@ -10,10 +10,22 @@ framework:
options: options:
use_notify: true use_notify: true
check_delayed_interval: 60000 check_delayed_interval: 60000
queue_name: default
retry_strategy: retry_strategy:
max_retries: 1 max_retries: 1
multiplier: 1 multiplier: 1
media_cache:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: media_cache
retry_strategy:
max_retries: 1
multiplier: 1
failed: 'doctrine://default?queue_name=failed' failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default default_bus: messenger.bus.default
@@ -29,7 +41,7 @@ framework:
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async 'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async 'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async 'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': async 'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache
# when@test: # when@test:
# framework: # framework:

View File

@@ -0,0 +1,7 @@
# Read the documentation: https://symfony.com/doc/current/bundles/StofDoctrineExtensionsBundle/index.html
# See the official DoctrineExtensions documentation for more details: https://github.com/doctrine-extensions/DoctrineExtensions/tree/main/doc
stof_doctrine_extensions:
default_locale: en_US
orm:
default:
timestampable: true

View File

@@ -1,5 +1,10 @@
twig: twig:
globals:
version: '%app.version%'
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
date:
format: 'm/d/Y'
timezone: '%env(default:app.default.timezone:TZ)%'
when@test: when@test:
twig: twig:

View File

@@ -29,3 +29,19 @@ controllersMonitor:
type: attribute type: attribute
defaults: defaults:
schemes: ['https'] schemes: ['https']
controllersTorrentio:
resource:
path: ../src/Torrentio/Framework/Controller
namespace: App\Torrentio\Framework\Controller
type: attribute
defaults:
schemes: ['https']
controllersTmdb:
resource:
path: ../src/Tmdb/Framework/Controller
namespace: App\Tmdb\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -0,0 +1,3 @@
ux_autocomplete:
resource: '@AutocompleteBundle/config/routes.php'
prefix: '/autocomplete'

View File

@@ -5,9 +5,10 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
# Media # Media
media.base_path: '/var/download'
media.default_movies_dir: movies media.default_movies_dir: movies
media.default_tvshows_dir: tvshows media.default_tvshows_dir: tvshows
media.movies_path: '%env(default:media.default_movies_dir:MOVIES_PATH)%' media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%' media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
# Mercure # Mercure
@@ -20,6 +21,12 @@ parameters:
app.cache.adapter.default: 'filesystem' app.cache.adapter.default: 'filesystem'
app.cache.redis.host.default: 'redis://redis' app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.version: '0.dev'
app.default.timezone: 'America/Chicago'
app.version: '%env(default:app.default.version:APP_VERSION)%'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
_defaults: _defaults:

View File

@@ -6,6 +6,7 @@ services:
environment: environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
deploy: deploy:
replicas: 2 replicas: 2
volumes: volumes:

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime" ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \ RUN install-php-extensions \
pdo_mysql \ pdo_mysql \
gd \ gd \

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime" ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \ RUN install-php-extensions \
pdo_mysql \ pdo_mysql \
gd \ gd \

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime" ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \ RUN install-php-extensions \
pdo_mysql \ pdo_mysql \
gd \ gd \

View File

@@ -3,8 +3,6 @@
frankenphp { frankenphp {
{$FRANKENPHP_CONFIG} {$FRANKENPHP_CONFIG}
} }
} }

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Torsearch media cache warming services
[Service]
ExecStart=php /app/bin/console messenger:consume media_cache --time-limit=3600
# for Redis, set a custom consumer name for each instance
Environment="MESSENGER_CONSUMER_NAME=symfony-%n-%i"
Restart=always
RestartSec=30
[Install]
WantedBy=default.target

View File

@@ -27,7 +27,7 @@ DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-Ma
# Popular Movies and TV Shows section. # Popular Movies and TV Shows section.
#TMDB_API= #TMDB_API=
REAL_DEBRID_KEY="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ" REAL_DEBRID_KEY=""
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw

View File

@@ -11,6 +11,13 @@ services:
- '8006:80' - '8006:80'
env_file: env_file:
- .env - .env
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
@@ -27,6 +34,8 @@ services:
volumes: volumes:
- /mnt/media/downloads/movies:/var/download/movies - /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows - /mnt/media/downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
command: -vvv command: -vvv
env_file: env_file:
- .env - .env
@@ -43,35 +52,17 @@ services:
scheduler: scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes: volumes:
- ./downloads:/var/download - /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
env_file: env_file:
- .env - .env
environment:
TZ: America/Chicago
restart: always restart: always
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
# This container facilitates viewing the progress of downloads
# in realtime. It also handles sending alerts and notifications.
# The MERCURE_PUBLISHER_JWT key & MERCURE_SUBSCRIBER_JWT_KEY should
# match the MERCURE_JWT_SECRET environment variable.
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3001:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database: database:
image: mariadb:10.11.2 image: mariadb:10.11.2
volumes: volumes:

View File

@@ -28,4 +28,40 @@ return [
'@hotwired/turbo' => [ '@hotwired/turbo' => [
'version' => '7.3.0', 'version' => '7.3.0',
], ],
'@stimulus-components/popover' => [
'version' => '7.0.0',
],
'@stimulus-components/dialog' => [
'version' => '1.0.1',
],
'@stimulus-components/dropdown' => [
'version' => '3.0.0',
],
'stimulus-use' => [
'version' => '0.52.2',
],
'animate.css' => [
'version' => '4.1.1',
],
'animate.css/animate.min.css' => [
'version' => '4.1.1',
'type' => 'css',
],
'tom-select' => [
'version' => '2.4.3',
],
'@orchidjs/sifter' => [
'version' => '1.1.0',
],
'@orchidjs/unicode-variants' => [
'version' => '1.1.2',
],
'tom-select/dist/css/tom-select.default.min.css' => [
'version' => '2.4.3',
'type' => 'css',
],
'tom-select/dist/css/tom-select.default.css' => [
'version' => '2.4.3',
'type' => 'css',
],
]; ];

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 Version20250610195448 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 download ADD created_at DATETIME NULL, ADD updated_at DATETIME 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 download DROP created_at, DROP updated_at
SQL);
}
}

View File

@@ -0,0 +1,50 @@
<?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 Version20250610222503 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 parent_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD CONSTRAINT FK_E1159985727ACA70 FOREIGN KEY (parent_id) REFERENCES monitor (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_E1159985727ACA70 ON monitor (parent_id)
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 FOREIGN KEY FK_E1159985727ACA70
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_E1159985727ACA70 ON monitor
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP parent_id
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
}
}

View File

@@ -3,7 +3,11 @@
namespace App\Controller; namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -12,15 +16,13 @@ use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController final class IndexController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly DownloadRepository $downloadRepository,
private readonly Tmdb $tmdb, private readonly Tmdb $tmdb,
private readonly MonitorTvShowHandler $monitorTvShowHandler,
) {} ) {}
#[Route('/', name: 'app_index')] #[Route('/', name: 'app_index')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
$request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->render('index/index.html.twig', [ return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(), 'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(), 'recent_downloads' => $this->getUser()->getDownloads(),

View File

@@ -33,12 +33,12 @@ final class SearchController extends AbstractController
]); ]);
} }
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')] #[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
public function result( public function result(
GetMediaInfoInput $input, GetMediaInfoInput $input,
?int $season = null,
): Response { ): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand()); $result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
return $this->render('search/result.html.twig', [ return $this->render('search/result.html.twig', [
'results' => $result, 'results' => $result,

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<PauseDownloadCommand>
*/
class PauseDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<ResumeDownloadCommand>
*/
class ResumeDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

@@ -38,7 +38,9 @@ readonly class DownloadMediaHandler implements HandlerInterface
} }
try { try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress'); if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
}
$this->downloader->download( $this->downloader->download(
$command->mediaType, $command->mediaType,
@@ -47,7 +49,9 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download->getId() $download->getId()
); );
$this->downloadRepository->updateStatus($download->getId(), 'Complete'); if ($download->getStatus() !== 'Paused') {
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
}
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500); throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\PauseDownloadCommand;
use App\Download\Action\Result\PauseDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Contracts\Cache\CacheInterface;
/** @implements HandlerInterface<PauseDownloadCommand, PauseDownloadResult> */
readonly class PauseDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private CacheInterface $cache,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$this->cache->get('download.pause.' . $download->getId(), function () {
return true;
});
return new PauseDownloadResult(200, 'Success', $download);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\ResumeDownloadCommand;
use App\Download\Action\Result\ResumeDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<ResumeDownloadCommand, ResumeDownloadResult> */
readonly class ResumeDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private MessageBusInterface $messageBus,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $download */
$download = $this->downloadRepository->find($command->downloadId);
$this->messageBus->dispatch(new DownloadMediaCommand(
$download->getUrl(),
$download->getTitle(),
$download->getFilename(),
$download->getMediaType(),
$download->getImdbId(),
$download->getUser()->getId(),
$download->getId()
));
return new ResumeDownloadResult(200, 'Success', $download);
}
}

View File

@@ -26,6 +26,9 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')] #[SourceRequest('imdbId')]
public string $imdbId, public string $imdbId,
#[SourceRequest('episodeId', nullify: true)]
public ?string $episodeId = null,
public ?int $userId = null, public ?int $userId = null,
public ?int $downloadId = null, public ?int $downloadId = null,

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\PauseDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<PauseDownloadInput, PauseDownloadCommand> */
class PauseDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new PauseDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\PauseDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<PauseDownloadInput, PauseDownloadCommand> */
class ResumeDownloadInput implements InputInterface
{
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
) {}
public function toCommand(): CommandInterface
{
return new PauseDownloadCommand(
$this->downloadId,
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<PauseDownloadResult> */
class PauseDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
) {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Download\Action\Result;
use App\Download\Framework\Entity\Download;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<ResumeDownloadResult> */
class ResumeDownloadResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public Download $download,
) {}
}

View File

@@ -5,14 +5,21 @@ namespace App\Download\Downloader;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\Monitor\Service\MediaFiles; use App\Monitor\Service\MediaFiles;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
use Symfony\Contracts\Cache\CacheInterface;
class ProcessDownloader implements DownloaderInterface class ProcessDownloader implements DownloaderInterface
{ {
/**
* @var RedisAdapter $cache
*/
public function __construct( public function __construct(
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private CacheInterface $cache,
) {} ) {}
/** /**
@@ -22,16 +29,23 @@ class ProcessDownloader implements DownloaderInterface
{ {
/** @var Download $downloadEntity */ /** @var Download $downloadEntity */
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId); $downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
$downloadEntity->setProgress(0);
$this->entityManager->flush(); $this->entityManager->flush();
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences(); $downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences); $path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
$process = (new Process([ $processArgs = ['wget', $url];
'wget',
$url if ($downloadEntity->getStatus() === 'Paused') {
]))->setWorkingDirectory($path); $downloadEntity->setStatus('In Progress');
$processArgs = ['wget', '-c', $url];
} else {
$downloadEntity->setProgress(0);
}
fwrite(STDOUT, implode(" ", $processArgs));
$process = (new Process($processArgs))->setWorkingDirectory($path);
$process->setTimeout(1800); // 30 min $process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min $process->setIdleTimeout(600); // 10 min
@@ -41,7 +55,18 @@ class ProcessDownloader implements DownloaderInterface
try { try {
$progress = 0; $progress = 0;
$this->entityManager->flush(); $this->entityManager->flush();
$process->wait(function ($type, $buffer) use ($progress, $downloadEntity): void {
$process->wait(function ($type, $buffer) use ($progress, $downloadEntity, $process): void {
// The PauseDownloadHandler will set this to 'true'
$doPause = $this->cache->getItem('download.pause.' . $downloadEntity->getId());
if (true === $doPause->isHit() && true === $doPause->get()) {
$downloadEntity->setStatus('Paused');
$this->cache->deleteItem('download.pause.' . $downloadEntity->getId());
$this->entityManager->flush();
$process->stop();
}
if (Process::ERR === $type) { if (Process::ERR === $type) {
$pregMatchOutput = []; $pregMatchOutput = [];
preg_match('/[\d]+%/', $buffer, $pregMatchOutput); preg_match('/[\d]+%/', $buffer, $pregMatchOutput);
@@ -56,7 +81,9 @@ class ProcessDownloader implements DownloaderInterface
} }
fwrite(STDOUT, $buffer); fwrite(STDOUT, $buffer);
}); });
$downloadEntity->setProgress(100); if ($downloadEntity->getStatus() !== 'Paused') {
$downloadEntity->setProgress(100);
}
} catch (ProcessFailedException $exception) { } catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed'); $downloadEntity->setStatus('Failed');
} }

View File

@@ -3,10 +3,15 @@
namespace App\Download\Framework\Controller; namespace App\Download\Framework\Controller;
use App\Download\Action\Handler\DeleteDownloadHandler; use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput; use App\Download\Action\Input\DeleteDownloadInput;
use App\Download\Action\Input\DownloadMediaInput; use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Broadcaster; use App\Util\Broadcaster;
use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -24,6 +29,13 @@ class ApiController extends AbstractController
public function download( public function download(
DownloadMediaInput $input, DownloadMediaInput $input,
): Response { ): Response {
$ptn = (object) new Ptn()->parse($input->filename);
if ($input->mediaType === "tvshows" &&
!property_exists($ptn, 'episode') && !property_exists($ptn, 'season')
) {
$input->filename = $input->episodeId . '_' . $input->filename;
}
$download = $this->downloadRepository->insert( $download = $this->downloadRepository->insert(
$this->getUser(), $this->getUser(),
$input->url, $input->url,
@@ -65,4 +77,32 @@ class ApiController extends AbstractController
return $this->json(['status' => 200, 'message' => 'Download Deleted']); return $this->json(['status' => 200, 'message' => 'Download Deleted']);
} }
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]
public function pauseDownload(
PauseDownloadInput $input,
PauseDownloadHandler $handler,
): Response {
$result = $handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "{$result->download->getTitle()} has been Paused.",
);
return $this->json(['status' => 200, 'message' => 'Download Paused']);
}
#[Route('/api/download/{downloadId}/resume', name: 'api_download_resume', methods: ['PATCH'])]
public function resumeDownload(
ResumeDownloadInput $input,
ResumeDownloadHandler $handler,
): Response {
$result = $handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "{$result->download->getTitle()} has been Resumed.",
);
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
}
} }

View File

@@ -5,12 +5,16 @@ namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)] #[ORM\Entity(repositoryClass: DownloadRepository::class)]
#[Broadcast(template: 'broadcast/Download.stream.html.twig')] #[Broadcast(template: 'broadcast/Download.stream.html.twig')]
class Download class Download
{ {
use TimestampableEntity;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
@@ -162,4 +166,16 @@ class Download
return $this; return $this;
} }
public function getPtn(): object
{
$ptn = (object) (new PTN())->parse($this->filename);
if ($this->mediaType === "tvshows") {
$ptn->season = str_pad($ptn->season, 2, "0", STR_PAD_LEFT);
$ptn->episode = str_pad($ptn->episode, 2, "0", STR_PAD_LEFT);
}
return $ptn;
}
} }

View File

@@ -25,27 +25,31 @@ class DownloadRepository extends ServiceEntityRepository
$this->paginator = $paginator; $this->paginator = $paginator;
} }
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10): Paginator public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10, string $term = ""): Paginator
{ {
$query = $this->createQueryBuilder('d') $query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)') ->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user') ->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.filename LIKE :term OR d.imdbId LIKE :term OR d.status LIKE :term OR d.mediaType LIKE :term)')
->orderBy('d.id', 'DESC') ->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete']) ->setParameter('statuses', ['Complete'])
->setParameter('user', $user) ->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery(); ->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage); return $this->paginator->paginate($query, $pageNumber, $perPage);
} }
public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5): Paginator public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5, string $term = ""): Paginator
{ {
$query = $this->createQueryBuilder('d') $query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)') ->andWhere('d.status IN (:statuses)')
->andWhere('d.user = :user') ->andWhere('d.user = :user')
->andWhere('(d.title LIKE :term OR d.filename LIKE :term OR d.imdbId LIKE :term OR d.status LIKE :term OR d.mediaType LIKE :term)')
->orderBy('d.id', 'ASC') ->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress']) ->setParameter('statuses', ['New', 'In Progress', 'Paused'])
->setParameter('user', $user) ->setParameter('user', $user)
->setParameter('term', '%' . $term . '%')
->getQuery(); ->getQuery();
return $this->paginator->paginate($query, $pageNumber, $perPage); return $this->paginator->paginate($query, $pageNumber, $perPage);

View File

@@ -5,10 +5,13 @@ namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult; use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator; use App\Monitor\Service\MonitorOptionEvaluator;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -27,12 +30,26 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
private MessageBusInterface $bus, private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
private MonitorRepository $monitorRepository, private MonitorRepository $monitorRepository,
private Tmdb $tmdb,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler'); $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::now()) {
$this->logger->info('> [MonitorTvEpisodeHandler] Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'message' => 'No change',
'monitor' => $monitor,
]
);
}
$monitor->setStatus('In Progress'); $monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush(); $this->monitorRepository->getEntityManager()->flush();
@@ -71,7 +88,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush(); $this->entityManager->flush();
return new MonitorMovieResult( return new MonitorTvEpisodeResult(
status: 'OK', status: 'OK',
result: [ result: [
'monitor' => $monitor, 'monitor' => $monitor,

View File

@@ -3,9 +3,9 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use Aimeos\Map; use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand; use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorMovieResult; use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Result\MonitorTvSeasonResult;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles; use App\Monitor\Service\MediaFiles;
@@ -17,16 +17,14 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */ /** @implements HandlerInterface<MonitorTvSeasonCommand> */
readonly class MonitorTvSeasonHandler implements HandlerInterface readonly class MonitorTvSeasonHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MonitorRepository $monitorRepository, private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
private Tmdb $tmdb, private Tmdb $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler, private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
@@ -41,33 +39,42 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
$downloadedEpisodes = $this->mediaFiles $downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle()) ->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode)) ->map(fn($episode) => (object) (new PTN())->parse($episode))
->filter(fn ($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
)
->rekey(fn($episode) => $episode->episode); ->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); $this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB // Compare against list from TMDB
$episodesInSeason = Map::from( $episodesInSeason = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId()) $this->tmdb->tvDetails($monitor->getTmdbId())->episodes[$monitor->getSeason()]
->episodes[$monitor->getSeason()]
)->rekey(fn($episode) => $episode['episode_number']); )->rekey(fn($episode) => $episode['episode_number']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle()); $this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode if ($downloadedEpisodes->count() !== $episodesInSeason->count()) {
foreach ($episodesInSeason as $episode) { // Dispatch Episode commands for each missing Episode
$monitorCheck = $this->monitorRepository->findOneBy([ foreach ($episodesInSeason as $episode) {
'imdbId' => $monitor->getImdbId(), // Check if the episode is already downloaded
'title' => $monitor->getTitle(), $episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
'monitorType' => 'tvepisode', $this->logger->info('> [MonitorTvSeasonHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO'));
'season' => $monitor->getSeason(), if (true === $episodeExists) {
'episode' => $episode['episode_number'], $this->logger->info('> [MonitorTvSeasonHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping');
'status' => ['New', 'Active', 'In Progress'] continue;
]); }
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $monitor->getSeason() . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (null !== $monitorCheck ? 'YES' : 'NO')); // Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
if (!array_key_exists($episode['episode_number'], $downloadedEpisodes->toArray())
&& null === $monitorCheck
) {
$episodeMonitor = (new Monitor()) $episodeMonitor = (new Monitor())
->setParent($monitor)
->setUser($monitor->getUser()) ->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId()) ->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId()) ->setImdbId($monitor->getImdbId())
@@ -88,16 +95,38 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
} }
} }
// Set the status to Active, so it will be re-executed.
$monitor->setStatus('Active');
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush(); $this->entityManager->flush();
return new MonitorMovieResult( return new MonitorTvSeasonResult(
status: 'OK', status: 'OK',
result: [ result: [
'monitor' => $monitor, 'monitor' => $monitor,
] ]
); );
} }
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(
fn (object $episode) => $episode->episode === $episodeInShow['episode_number']
&& $episode->season === $episodeInShow['season_number']
)->count() > 0;
}
private function monitorExists(Monitor $monitor, array $episode): bool
{
return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $episode['season_number'],
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
} }

View File

@@ -5,11 +5,12 @@ namespace App\Monitor\Action\Handler;
use Aimeos\Map; use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand; use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult; use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles; use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN; use Nihilarr\PTN;
@@ -17,7 +18,6 @@ use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */ /** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvShowHandler implements HandlerInterface readonly class MonitorTvShowHandler implements HandlerInterface
@@ -25,8 +25,8 @@ readonly class MonitorTvShowHandler implements HandlerInterface
public function __construct( public function __construct(
private MonitorRepository $monitorRepository, private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
private Tmdb $tmdb, private Tmdb $tmdb,
) {} ) {}
@@ -39,55 +39,116 @@ readonly class MonitorTvShowHandler implements HandlerInterface
// Check current episodes // Check current episodes
$downloadedEpisodes = $this->mediaFiles $downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle()) ->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode)); ->map(fn($episode) => (object) (new PTN())->parse($episode))
->filter(fn ($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
)
;
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); $this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB // Compare against list from TMDB
$episodesInShow = Map::from( $episodesInShow = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId()) $this->tmdb->tvDetails($monitor->getTmdbId())->episodes
->episodes
)->flat(1); )->flat(1);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode $this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle());
foreach ($episodesInShow as $episode) {
$episodeAlreadyDownloaded = $downloadedEpisodes->find(
fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number']
);
$episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded);
if (false === $episodeAlreadyDownloaded) { $episodeMonitors = [];
$monitor = (new Monitor()) if ($downloadedEpisodes->count() !== $episodesInShow->count()) {
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
// Only monitor future episodes
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping');
continue;
}
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Create the monitor
$episodeMonitor = (new Monitor())
->setParent($monitor)
->setUser($monitor->getUser()) ->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId()) ->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId()) ->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle()) ->setTitle($monitor->getTitle())
->setMonitorType('tvshow') ->setMonitorType('tvepisode')
->setSeason($episode['season_number']) ->setSeason($episode['season_number'])
->setEpisode($episode['episode_number']) ->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable()) ->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0) ->setSearchCount(0)
->setStatus('New'); ->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($monitor); $this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush(); $this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($monitor->getId()); $episodeMonitors[] = $episodeMonitor;
$this->bus->dispatch($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle()); // Immediately run the monitor
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
} }
} }
// Set the status to Active, so it will be re-executed.
$monitor->setStatus('Active');
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush(); $this->entityManager->flush();
return new MonitorTvEpisodeResult( return new MonitorTvShowResult(
status: 'OK', status: 'OK',
result: [ result: [
'monitor' => $monitor, 'monitor' => $monitor,
'new_monitors' => $episodeMonitors,
] ]
); );
} }
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, array $episodeInShow): bool
{
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow['air_date']);
return $episodeAirDate >= $monitorStartDate;
}
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(
fn (object $episode) => $episode->episode === $episodeInShow['episode_number']
&& $episode->season === $episodeInShow['season_number']
)->count() > 0;
}
private function monitorExists(Monitor $monitor, array $episode): bool
{
return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $episode['season_number'],
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]) !== null;
}
} }

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorTvSeasonResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorTvShowResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,17 @@
<?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,17 +2,15 @@
namespace App\Monitor\Framework\Controller; namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Monitor\Action\Handler\AddMonitorHandler; use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler; use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput; use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use App\Util\Broadcaster; use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController class ApiController extends AbstractController
@@ -46,22 +44,23 @@ class ApiController extends AbstractController
public function deleteMonitor( public function deleteMonitor(
DeleteMonitorInput $input, DeleteMonitorInput $input,
DeleteMonitorHandler $handler, DeleteMonitorHandler $handler,
HubInterface $hub,
) { ) {
$response = $handler->handle($input->toCommand()); $response = $handler->handle($input->toCommand());
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$response->monitor->getTitle()}",
])
));
return $this->json([ return $this->json([
'status' => 200, 'status' => 200,
'message' => $response 'message' => $response
]); ]);
} }
#[Route('/api/monitor/dispatch', name: 'api_monitor_dispatch', methods: ['GET'])]
public function dispatch(MonitorDispatcher $dispatcher): Response
{
$dispatcher();
return $this->json([
'status' => 200,
'message' => 'Manually dispatched MonitorDispatcher'
]);
}
} }

View File

@@ -4,6 +4,9 @@ namespace App\Monitor\Framework\Entity;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use Carbon\Carbon;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Attribute\Ignore;
@@ -56,6 +59,17 @@ class Monitor
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $downloadedAt = null; private ?\DateTimeImmutable $downloadedAt = null;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
private ?self $parent = null;
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
private Collection $children;
public function __construct()
{
$this->children = new ArrayCollection();
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -135,7 +149,7 @@ class Monitor
public function getLastSearch(): ?\DateTimeInterface public function getLastSearch(): ?\DateTimeInterface
{ {
return $this->lastSearch; return Carbon::parse($this->lastSearch);
} }
public function setLastSearch(?\DateTimeInterface $lastSearch): static public function setLastSearch(?\DateTimeInterface $lastSearch): static
@@ -204,4 +218,51 @@ class Monitor
return $this; return $this;
} }
public function getParent(): ?self
{
return $this->parent;
}
public function setParent(?self $parent): static
{
$this->parent = $parent;
return $this;
}
/**
* @return Collection<int, self>
*/
public function getChildren(): Collection
{
return $this->children;
}
public function addChild(self $child): static
{
if (!$this->children->contains($child)) {
$this->children->add($child);
$child->setParent($this);
}
return $this;
}
public function removeChild(self $child): static
{
if ($this->children->removeElement($child)) {
// set the owning side to null (unless already changed)
if ($child->getParent() === $this) {
$child->setParent(null);
}
}
return $this;
}
public function isActive(): bool
{
return in_array($this->status, ['New', 'Active', 'In Progress']);
}
} }

View File

@@ -21,7 +21,7 @@ class MonitorRepository extends ServiceEntityRepository
$this->paginator = $paginator; $this->paginator = $paginator;
} }
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage): Paginator public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage, string $searchTerm): Paginator
{ {
$query = $this->createQueryBuilder('m') $query = $this->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)') ->andWhere('m.status IN (:statuses)')

View File

@@ -7,11 +7,12 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand; use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Command\MonitorTvShowCommand; use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use Carbon\Carbon;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('* * * * *', schedule: 'monitor')] #[AsCronTask('0 * * * *', schedule: 'monitor')]
class MonitorDispatcher class MonitorDispatcher
{ {
public function __construct( public function __construct(
@@ -23,6 +24,8 @@ class MonitorDispatcher
public function __invoke() { public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher'); $this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$this->cleanupStuckMonitors();
$monitorHandlers = [ $monitorHandlers = [
'movie' => MonitorMovieCommand::class, 'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class, 'tvepisode' => MonitorTvEpisodeCommand::class,
@@ -41,4 +44,18 @@ class MonitorDispatcher
$this->bus->dispatch(new $command($monitor->getId())); $this->bus->dispatch(new $command($monitor->getId()));
} }
} }
private function cleanupStuckMonitors(): void
{
$hoursStuck = 4;
$monitors = $this->monitorRepository->findBy(['status' => 'In Progress']);
foreach ($monitors as $monitor) {
// Reset the status to active so it will be executed again
if ($monitor->getLastSearch()->diffInHours(Carbon::today()) > $hoursStuck) {
$this->logger->info('[MonitorDispatcher] Cleaning up monitor: ' . $monitor->getId() . ' (' . $monitor->getTitle() . '), resetting status to \'Active\' from \''. $monitor->getStatus() .'\' after being stuck for ' . $hoursStuck . ' hours.');
$monitor->setStatus('Active');
}
}
$this->monitorRepository->getEntityManager()->flush();
}
} }

View File

@@ -3,14 +3,19 @@
namespace App\Monitor\Service; namespace App\Monitor\Service;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use Nihilarr\PTN;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class MediaFiles class MediaFiles
{ {
private Finder $finder; private Finder $finder;
private string $basePath;
private string $moviesPath; private string $moviesPath;
private string $tvShowsPath; private string $tvShowsPath;
@@ -18,6 +23,9 @@ class MediaFiles
private Filesystem $filesystem; private Filesystem $filesystem;
public function __construct( public function __construct(
#[Autowire(param: 'media.base_path')]
string $basePath,
#[Autowire(param: 'media.movies_path')] #[Autowire(param: 'media.movies_path')]
string $moviesPath, string $moviesPath,
@@ -27,6 +35,7 @@ class MediaFiles
Filesystem $filesystem, Filesystem $filesystem,
) { ) {
$this->finder = new Finder(); $this->finder = new Finder();
$this->basePath = $basePath;
$this->moviesPath = $moviesPath; $this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath; $this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem; $this->filesystem = $filesystem;
@@ -43,6 +52,11 @@ class MediaFiles
throw new \Exception(sprintf('A path for media type %s does not exist.', $mediaType)); throw new \Exception(sprintf('A path for media type %s does not exist.', $mediaType));
} }
public function getBasePath(): string
{
return $this->basePath;
}
public function getMoviesPath(): string public function getMoviesPath(): string
{ {
return $this->moviesPath; return $this->moviesPath;
@@ -125,4 +139,85 @@ class MediaFiles
return $path; return $path;
} }
public function episodeExists(string $tvshowTitle, int $seasonNumber, int $episodeNumber)
{
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
if ($existingEpisodes->isEmpty()) {
return false;
}
/** @var SplFileInfo $episode */
foreach ($existingEpisodes as $episode) {
$ptn = (object) (new PTN())->parse($episode->getFilename());
if (!property_exists($ptn, 'season') || !property_exists($ptn, 'episode')) {
continue;
}
if ($ptn->season === $seasonNumber && $ptn->episode === $episodeNumber) {
return $episode;
}
}
return false;
}
public function movieExists(string $title)
{
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $title;
$directoryExists = $this->filesystem->exists($filepath);
if (false === $directoryExists) {
return false;
}
if (false === $this->finder->in($filepath)->files()->hasResults()) {
return false;
}
$files = Map::from($this->finder->in($filepath)->files())->filter(function (SplFileInfo $file) {
$validExtensions = ['mkv', 'mp4', 'mpeg'];
return in_array($file->getExtension(), $validExtensions);
})->values();
if (false === $files->isEmpty()) {
return $files[0];
}
return false;
}
public function getDownloadPath(Download $download): string
{
$basePath = $this->getBasePath();
if ($download->getMediaType() === 'movies') {
$basePath = $this->getMoviesPath();
if ($download->getUser()->hasUserPreference('movie_folder')) {
$basePath .= DIRECTORY_SEPARATOR . $download->getTitle();
}
} elseif ($download->getMediaType() === 'tvshows') {
$basePath = $this->getTvShowsPath() . DIRECTORY_SEPARATOR . $download->getTitle();
}
$filepath = $basePath . DIRECTORY_SEPARATOR . $download->getFilename();
if (false === $this->filesystem->exists($filepath)) {
throw new \Exception(sprintf('File %s does not exist.', $filepath));
}
return $filepath;
}
public function renameFile(string $oldFile, string $newFile)
{
$this->filesystem->rename($oldFile, $newFile);
}
public function setFilePermissions(string $filepath, int $permissions)
{
$this->filesystem->chmod($filepath, $permissions);
}
} }

View File

@@ -8,7 +8,8 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class GetMediaInfoCommand implements CommandInterface class GetMediaInfoCommand implements CommandInterface
{ {
public function __construct( public function __construct(
public string $tmdbId, public string $imdbId,
public string $mediaType, public string $mediaType,
public ?int $season = null,
) {} ) {}
} }

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Search\Action\Input; namespace App\Search\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Enum\MediaType;
use App\Search\Action\Command\GetMediaInfoCommand; use App\Search\Action\Command\GetMediaInfoCommand;
use OneToMany\RichBundle\Attribute\SourceRoute; use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -12,15 +13,21 @@ use OneToMany\RichBundle\Contract\InputInterface;
class GetMediaInfoInput implements InputInterface class GetMediaInfoInput implements InputInterface
{ {
public function __construct( public function __construct(
#[SourceRoute('tmdbId')] #[SourceRoute('imdbId')]
public string $tmdbId, public string $imdbId,
#[SourceRoute('mediaType')] #[SourceRoute('mediaType')]
public string $mediaType, public string $mediaType,
#[SourceRoute('season', nullify: true)]
public ?int $season,
) {} ) {}
public function toCommand(): CommandInterface public function toCommand(): CommandInterface
{ {
return new GetMediaInfoCommand($this->tmdbId, $this->mediaType); if ("tvshows" === $this->mediaType && null === $this->season) {
$this->season = 1;
}
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season);
} }
} }

View File

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

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Search;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Result\GetMediaInfoResult;
use stdClass;
class TvEpisodePaginator
{
/**
* @var integer
*/
private $total;
/**
* @var integer
*/
private $lastPage;
private $items;
public $limit = 20;
public $currentPage = 1;
public function paginate(GetMediaInfoResult $results, int $page = 1, int $limit = 20): static
{
$this->total = count($results->media->episodes[$results->season]);
$this->lastPage = (int) ceil($this->total / $limit);
$this->items = array_slice($results->media->episodes[$results->season], ($page - 1) * $limit, $limit);
$this->currentPage = $page;
$this->limit = $limit;
return $this;
}
public function getTotal(): int
{
return $this->total;
}
public function getLastPage(): int
{
return $this->lastPage;
}
public function getItems()
{
return $this->items;
}
public function getShowing()
{
$showingStart = (($this->currentPage - 1) * $this->limit) + 1;
$showingEnd = (($this->currentPage - 1) * $this->limit) + $this->limit;
if ($showingEnd > $this->total) {
$showingEnd = $this->total;
}
return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Tmdb\Framework\Controller;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
#[Route('/api/tmdb/ajax-search', name: 'api_tmdb_ajax_search', methods: ['GET'])]
public function test(Tmdb $tmdb, Request $request): Response
{
$results = [];
$term = $request->query->get('query') ?? null;
if (null !== $term) {
$tmdbResults = $tmdb->search($term);
foreach ($tmdbResults as $tmdbResult) {
/** @var TmdbResult $tmdbResult */
$results[] = [
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
];
}
}
return $this->json([
'results' => $results,
]);
}
}

View File

@@ -2,8 +2,12 @@
namespace App\Tmdb; namespace App\Tmdb;
use Aimeos\Map;
use App\Enum\MediaType; use App\Enum\MediaType;
use App\ValueObject\ResultFactory; use App\ValueObject\ResultFactory;
use Carbon\Carbon;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -11,6 +15,7 @@ use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Api\Find; use Tmdb\Api\Find;
use Tmdb\Client; use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent; use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener; use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener; use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener; use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
@@ -39,7 +44,7 @@ class Tmdb
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500"; const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct( public function __construct(
private readonly CacheInterface $cache, private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
) { ) {
@@ -70,7 +75,13 @@ class Tmdb
/** /**
* Required event listeners and events to be registered with the PSR-14 Event Dispatcher. * Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
*/ */
$requestListener = new RequestListener($this->client->getHttpClient(), $this->eventDispatcher); $requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(),
$this->eventDispatcher,
$cache,
$this->client->getHttpClient()->getPsr17StreamFactory(),
[]
);
$this->eventDispatcher->addListener(RequestEvent::class, $requestListener); $this->eventDispatcher->addListener(RequestEvent::class, $requestListener);
$apiTokenListener = new ApiTokenRequestListener($this->client->getToken()); $apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
@@ -97,6 +108,16 @@ class Tmdb
return $this->parseResult($movies[$movie], "movie"); return $this->parseResult($movies[$movie], "movie");
}); });
$movies = Map::from($movies->toArray())->filter(function ($movie) {
return $movie !== null
&& $movie->imdbId !== null
&& $movie->tmdbId !== null
&& $movie->title !== null
&& $movie->poster !== null
&& $movie->description !== null
&& $movie->mediaType !== null;
});
$movies = array_values($movies->toArray()); $movies = array_values($movies->toArray());
if (null !== $limit) { if (null !== $limit) {
@@ -114,6 +135,16 @@ class Tmdb
return $this->parseResult($movies[$movie], "movie"); return $this->parseResult($movies[$movie], "movie");
}); });
$movies = Map::from($movies->toArray())->filter(function ($movie) {
return $movie !== null
&& $movie->imdbId !== null
&& $movie->tmdbId !== null
&& $movie->title !== null
&& $movie->poster !== null
&& $movie->description !== null
&& $movie->mediaType !== null;
});
$movies = array_values($movies->toArray()); $movies = array_values($movies->toArray());
if (null !== $limit) { if (null !== $limit) {
@@ -133,9 +164,12 @@ class Tmdb
if (!$result instanceof Movie && !$result instanceof Tv) { if (!$result instanceof Movie && !$result instanceof Tv) {
continue; continue;
} }
$results[] = $this->parseResult($result); $results[] = $this->parseResult($result);
} }
$results = array_filter($results, fn ($result) => null !== $result->imdbId);
return $results; return $results;
} }
@@ -143,7 +177,16 @@ class Tmdb
{ {
$finder = new Find($this->client); $finder = new Find($this->client);
$result = $finder->findBy($id, ['external_source' => 'imdb_id']); $result = $finder->findBy($id, ['external_source' => 'imdb_id']);
return $this->parseResult($result);
if (count($result['movie_results']) > 0) {
return $result['movie_results'][0]['id'];
} elseif (count($result['tv_results']) > 0) {
return $result['tv_results'][0]['id'];
} elseif (count($result['tv_episode_results']) > 0) {
return $result['tv_episode_results'][0]['show_id'];
}
throw new \Exception("No results found for $id");
} }
public function movieDetails(string $id) public function movieDetails(string $id)
@@ -176,13 +219,20 @@ class Tmdb
continue; continue;
} }
$series['episodes'][$season['season_number']] = $client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']; $series['episodes'][$season['season_number']] = Map::from(
$client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']
)->map(function ($data) {
$data['poster'] = (null !== $data['still_path']) ? self::POSTER_IMG_PATH . $data['still_path'] : null;
return $data;
})->toArray();
} }
return $series; return $series;
} }
public function mediaDetails(string $id, string $type) public function mediaDetails(string $id, string $type)
{ {
$id = $this->find($id);
if ($type === "movies") { if ($type === "movies") {
return $this->movieDetails($id); return $this->movieDetails($id);
} else { } else {

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Action\Handler; namespace App\Torrentio\Action\Handler;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Torrentio\Action\Result\GetMovieOptionsResult; use App\Torrentio\Action\Result\GetMovieOptionsResult;
use App\Torrentio\Client\Torrentio; use App\Torrentio\Client\Torrentio;
@@ -14,12 +15,15 @@ class GetMovieOptionsHandler implements HandlerInterface
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio, private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$media = $this->tmdb->mediaDetails($command->imdbId, 'movies');
return new GetMovieOptionsResult( return new GetMovieOptionsResult(
media: $this->tmdb->mediaDetails($command->tmdbId, 'movies'), media: $media,
file: $this->mediaFiles->movieExists($media->title),
results: $this->torrentio->search($command->imdbId, 'movies'), results: $this->torrentio->search($command->imdbId, 'movies'),
); );
} }

View File

@@ -2,6 +2,7 @@
namespace App\Torrentio\Action\Handler; namespace App\Torrentio\Action\Handler;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -16,12 +17,18 @@ class GetTvShowOptionsHandler implements HandlerInterface
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio, private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$media = $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode);
$parentShow = $this->tmdb->mediaDetails($command->imdbId, 'tvshows');
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult( return new GetTvShowOptionsResult(
media: $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode), media: $media,
file: $file,
season: $command->season, season: $command->season,
episode: $command->episode, episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults( results: $this->torrentio->fetchEpisodeResults(

View File

@@ -9,6 +9,7 @@ class GetMovieOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $media, public TmdbResult $media,
public bool|\SplFileInfo $file,
public array $results public array $results
) {} ) {}
} }

View File

@@ -4,11 +4,13 @@ namespace App\Torrentio\Action\Result;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Finder\SplFileInfo;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $media, public TmdbResult $media,
public bool|SplFileInfo $file,
public string $season, public string $season,
public string $episode, public string $episode,
public array $results public array $results

View File

@@ -6,63 +6,99 @@ use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine; use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\Result\ResultFactory; use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon; use Carbon\Carbon;
use App\Torrentio\Exception\TorrentioRateLimitException;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class Torrentio class Torrentio
{ {
private string $baseUrl = 'https://torrentio.strem.fun/providers%253Dyts%252Ceztv%252Crarbg%252C1337x%252Cthepiratebay%252Ckickasstorrents%252Ctorrentgalaxy%252Cmagnetdl%252Chorriblesubs%252Cnyaasi%7Csort%253Dqualitysize%7Cqualityfilter%253D480p%252Cscr%252Ccam%7Crealdebrid={realDebridKey}/stream/movie/{imdbCode}.json'; private string $baseUrl = 'https://torrentio.strem.fun/providers%253Dyts%252Ceztv%252Crarbg%252C1337x%252Cthepiratebay%252Ckickasstorrents%252Ctorrentgalaxy%252Cmagnetdl%252Chorriblesubs%252Cnyaasi%7Csort%253Dqualitysize%7Cqualityfilter%253D480p%252Cscr%252Ccam%7Crealdebrid={realDebridKey}/stream/movie';
private string $searchUrl; private string $searchUrl;
private Client $client;
public function __construct( public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey, #[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private CacheInterface $cache, private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
) { ) {
$this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl); $this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
$this->client = new Client([
'base_uri' => $this->searchUrl,
]);
} }
public function search(string $imdbCode, string $type, array $filter = []): array public function search(string $imdbCode, string $type, bool $parseResults = true): array
{ {
$cacheKey = "torrentio.{$imdbCode}"; $cacheKey = "torrentio.{$imdbCode}";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode) { $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$response = file_get_contents(str_replace('{imdbCode}', $imdbCode, $this->searchUrl)); $item->tag(['torrentio', $type, $imdbCode]);
return json_decode( try {
$response, $response = $this->client->get("$this->searchUrl/$imdbCode.json");
true return json_decode(
); $response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
}); });
return $this->parse($results, $filter); if (true === $parseResults) {
return $this->parse($results);
}
return $results;
} }
public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array
{ {
$cacheKey = "torrentio.$imdbId.$season.$episode"; $cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) { $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$response = file_get_contents(str_replace('{imdbCode}', "$imdbId:$season:$episode", $this->searchUrl)); $item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]);
return json_decode( try {
$response, $response = $this->client->get("$this->searchUrl/$imdbId:$season:$episode.json");
true return json_decode(
); $response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
}); });
return $this->parse($results, []); if (null === $results) {
} throw new TorrentioRateLimitException();
public function parse(array $data, array $filter): array
{
$ruleEngine = new RuleEngine();
foreach ($filter as $rule => $value) {
if ('resolution' === $rule) {
$ruleEngine->addRule(new Resolution($value));
}
} }
if (true === $parseResults) {
return $this->parse($results);
}
return $results;
}
public function parse(array $data): array
{
$results = []; $results = [];
foreach ($data['streams'] as $stream) { foreach ($data['streams'] as $stream) {
if (!str_starts_with($stream['url'], "https")) { if (!str_starts_with($stream['url'], "https")) {
@@ -84,9 +120,7 @@ class Torrentio
$bingeGroup $bingeGroup
); );
if ($ruleEngine->validateAll($result)) { $results[] = $result;
$results[] = $result;
}
} }
return $results; return $results;

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Torrentio\Exception;
class TorrentioRateLimitException extends \Exception
{
public function __construct()
{
parent::__construct(sprintf("[TorrentioClient] Rate limit exceeded"));
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Client\Torrentio;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class ApiController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly Broadcaster $broadcaster,
private readonly Torrentio $torrentio,
) {}
#[Route('/api/torrentio/{imdbId}/{season?}/{episode?}', name: 'api_torrentio')]
public function api(string $imdbId, ?int $season, ?int $episode): Response
{
if (null !== $season && null !== $episode) {
return $this->json(
$this->torrentio->fetchEpisodeResults($imdbId, $season, $episode, false)
);
}
return $this->json(
$this->torrentio->search($imdbId, 'movies', false),
);
}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
try {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
// });
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$cache->delete($cacheId);
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
}

View File

@@ -1,11 +1,12 @@
<?php <?php
namespace App\Controller; namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler; use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput; use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster; use App\Util\Broadcaster;
use Carbon\Carbon; use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
final class TorrentioController extends AbstractController final class WebController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler, private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
@@ -52,13 +53,24 @@ final class TorrentioController extends AbstractController
$input->episode, $input->episode,
); );
return $cache->get($cacheId, function (ItemInterface $item) use ($input) { try {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); // return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); // $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->render('torrentio/tvshows.html.twig', [ $results = $this->getTvShowOptionsHandler->handle($input->toCommand());
'results' => $results, return $this->render('torrentio/tvshows.html.twig', [
]); 'results' => $results,
}); ]);
// });
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
} }
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')] #[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]

View File

@@ -18,6 +18,9 @@ final class DownloadList extends AbstractController
use PaginateTrait; use PaginateTrait;
#[LiveProp(writable: true)]
public string $term = "";
#[LiveProp(writable: true)] #[LiveProp(writable: true)]
public string $type; public string $type;
@@ -31,9 +34,9 @@ final class DownloadList extends AbstractController
public function getDownloads() public function getDownloads()
{ {
if ($this->type === "active") { if ($this->type === "active") {
return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage); return $this->downloadRepository->getActivePaginated($this->getUser(), $this->pageNumber, $this->perPage, $this->term);
} elseif ($this->type === "complete") { } elseif ($this->type === "complete") {
return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage); return $this->downloadRepository->getCompletePaginated($this->getUser(), $this->pageNumber, $this->perPage, $this->term);
} }
return []; return [];

View File

@@ -11,6 +11,4 @@ final class DownloadListRow
public Download $download; public Download $download;
public bool $isWidget = true; public bool $isWidget = true;
public bool $isBroadcasted = false;
} }

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Modal
{
}

View File

@@ -17,6 +17,9 @@ final class MonitorList extends AbstractController
use PaginateTrait; use PaginateTrait;
#[LiveProp(writable: true)]
public string $term = "";
#[LiveProp(writable: true)] #[LiveProp(writable: true)]
public string $type; public string $type;
@@ -44,7 +47,9 @@ final class MonitorList extends AbstractController
{ {
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m') return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)') ->andWhere('m.status IN (:statuses)')
->setParameter('statuses', ['New', 'In Progress']) ->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->setParameter('statuses', ['New', 'In Progress', 'Active'])
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC') ->orderBy('m.id', 'DESC')
->getQuery() ->getQuery()
); );
@@ -55,7 +60,9 @@ final class MonitorList extends AbstractController
{ {
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m') return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
->andWhere('m.status = :status') ->andWhere('m.status = :status')
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
->setParameter('status', 'Complete') ->setParameter('status', 'Complete')
->setParameter('term', '%'.$this->term.'%')
->orderBy('m.id', 'DESC') ->orderBy('m.id', 'DESC')
->getQuery() ->getQuery()
); );

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Twig\Components;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\TvEpisodePaginator;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class TvEpisodeList
{
use DefaultActionTrait;
use PaginateTrait;
#[LiveProp(writable: true)]
public string $title = "";
#[LiveProp(writable: true)]
public string $imdbId = "";
#[LiveProp(writable: true)]
public string $tmdbId = "";
#[LiveProp(writable: true)]
public int $season = 1;
public function __construct(
private GetMediaInfoHandler $getMediaInfoHandler,
) {}
public function getEpisodes()
{
$results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season));
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
}
public function setPage(int $page)
{
$this->pageNumber = $page;
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use App\Monitor\Dto\UpcomingEpisode;
use App\Monitor\Framework\Entity\Monitor;
use App\Tmdb\Tmdb;
use Carbon\CarbonImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Tmdb\Model\Tv\Episode;
#[AsTwigComponent]
final class UpcomingEpisodes extends AbstractController
{
// Get active monitors
// Search TMDB for upcoming episodes
public function __construct(
private readonly Tmdb $tmdb,
) {}
public function getUpcomingEpisodes(int $limit = 5): array
{
$upcomingEpisodes = new Map();
$monitors = $this->getMonitors();
foreach ($monitors as $monitor) {
$upcomingEpisodes->merge($this->getNextEpisodes($monitor));
}
return $upcomingEpisodes->slice(0, $limit)->toArray();
}
private function getMonitors()
{
$user = $this->getUser();
return $user->getMonitors()->filter(
fn (Monitor $monitor) => null === $monitor->getParent() && $monitor->isActive()
) ?? [];
}
private function getNextEpisodes(Monitor $monitor): Map
{
$today = CarbonImmutable::now();
$seriesInfo = $this->tmdb->tvDetails($monitor->getTmdbId());
switch ($monitor->getMonitorType()) {
case "tvseason":
$episodes = Map::from($seriesInfo->episodes[$monitor->getSeason()])
->filter(function (array $episode) use ($today) {
$airDate = CarbonImmutable::parse($episode['air_date']);
return $airDate->lte($today);
})
;
break;
case "tvshows":
$episodes = [];
foreach ($seriesInfo->episodes as $season => $episodeList) {
$episodes = array_merge($episodes, $episodeList);
}
$episodes = Map::from($episodes)
->filter(function (array $episode) use ($today) {
$airDate = CarbonImmutable::parse($episode['air_date']);
return $airDate->gte($today);
})
;
break;
}
return $episodes->map(function (array $episode) use ($monitor) {
return new UpcomingEpisode(
$monitor->getTitle(),
$episode['air_date'],
$episode['name'],
$episode['episode_number'],
);
});
}
}

View File

@@ -19,6 +19,19 @@ class MonitorExtension
return $types[$type] ?? '-'; return $types[$type] ?? '-';
} }
#[AsTwigFilter('as_download_type')]
public function monitorTypeToDownloadType(string $type)
{
$types = [
'tvshows' => 'tvshows',
'tvseason' => 'tvshows',
'tvepisode' => 'tvshows',
'movie' => 'movies',
];
return $types[$type] ?? '-';
}
#[AsTwigFilter('monitor_media_id')] #[AsTwigFilter('monitor_media_id')]
public function mediaId(Monitor $monitor) public function mediaId(Monitor $monitor)
{ {

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Twig\Extensions;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Torrentio\Result\TorrentioResult;
use ChrisUllyott\FileSize;
use Tmdb\Model\Tv\Episode;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
class UtilExtension
{
public function __construct(
private readonly MediaFiles $mediaFiles,
) {}
#[AsTwigFunction('uniqid')]
public function uniqid(): string
{
return uniqid();
}
#[AsTwigFilter('truncate')]
public function truncate(string $text)
{
if (strlen($text) > 300) {
$text = substr($text, 0, 300) . '...';
}
return $text;
}
#[AsTwigFilter('filesize')]
public function type(string|int $size)
{
return (new FileSize($size))->asAuto();
}
#[AsTwigFilter('strip_media_path')]
public function stripMediaPath(string $path)
{
return str_replace(
$this->mediaFiles->getBasePath() . DIRECTORY_SEPARATOR,
'',
$path
);
}
#[AsTwigFilter('episode_id_from_results')]
public function episodeId($result): ?string
{
if (!$result instanceof GetTvShowOptionsResult) {
return null;
}
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($result->episode, 2, "0", STR_PAD_LEFT);
}
}

View File

@@ -17,13 +17,16 @@ use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface; use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController class RegistrationController extends AbstractController
{ {
public function __construct(private readonly RegisterUserHandler $registerUserHandler) public function __construct(private readonly RegisterUserHandler $registerUserHandler,
private readonly RequestStack $requestStack
)
{ {
} }
@@ -71,6 +74,7 @@ class RegistrationController extends AbstractController
)); ));
$security->login($user->user); $security->login($user->user);
$this->requestStack->getCurrentRequest()->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->redirectToRoute('app_index'); return $this->redirectToRoute('app_index');
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\User\Framework\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
final class LoginSuccessListener
{
public function __construct(
private readonly RequestStack $requestStack,
) {}
#[AsEventListener(event: 'security.authentication.success')]
public function setMercureTopics(AuthenticationSuccessEvent $event): void
{
// Set the unique Mercure topic name for the User's alerts
$this->requestStack->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
}
}

View File

@@ -17,7 +17,7 @@ readonly class Broadcaster
private RequestStack $requestStack, private RequestStack $requestStack,
) {} ) {}
public function alert(string $title, string $message): void public function alert(string $title, string $message, string $type = "success"): void
{ {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic'); $userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update( $update = new Update(
@@ -26,6 +26,7 @@ readonly class Broadcaster
'alert_id' => uniqid(), 'alert_id' => uniqid(),
'title' => $title, 'title' => $title,
'message' => $message, 'message' => $message,
'type' => $type,
]) ])
); );
$this->hub->publish($update); $this->hub->publish($update);

View File

@@ -22,6 +22,8 @@ class Paginator
public $currentPage = 1; public $currentPage = 1;
public $limit = 5;
/** /**
* @param QueryBuilder|Query $query * @param QueryBuilder|Query $query
* @param int $page * @param int $page
@@ -41,6 +43,7 @@ class Paginator
$this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults()); $this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults());
$this->items = $paginator; $this->items = $paginator;
$this->currentPage = $page; $this->currentPage = $page;
$this->limit = $limit;
return $this; return $this;
} }
@@ -59,4 +62,11 @@ class Paginator
{ {
return $this->items; return $this->items;
} }
public function getShowing()
{
$showingStart = ($this->currentPage - 1) * $this->limit;
$showingEnd = $showingStart + $this->limit;
return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total);
}
} }

View File

@@ -74,6 +74,18 @@
"phpstan.dist.neon" "phpstan.dist.neon"
] ]
}, },
"stof/doctrine-extensions-bundle": {
"version": "1.14",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.2",
"ref": "e805aba9eff5372e2d149a9ff56566769e22819d"
},
"files": [
"config/packages/stof_doctrine_extensions.yaml"
]
},
"symfony/asset-mapper": { "symfony/asset-mapper": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {
@@ -269,6 +281,18 @@
"templates/base.html.twig" "templates/base.html.twig"
] ]
}, },
"symfony/ux-autocomplete": {
"version": "2.27",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.6",
"ref": "07d9602b7231ba355f484305d6cea58310c01741"
},
"files": [
"config/routes/ux_autocomplete.yaml"
]
},
"symfony/ux-icons": { "symfony/ux-icons": {
"version": "2.24", "version": "2.24",
"recipe": { "recipe": {

View File

@@ -5,15 +5,48 @@ module.exports = {
"./templates/**/*.html.twig", "./templates/**/*.html.twig",
], ],
safelist: [ safelist: [
"flex",
"flex-col",
"flex-row",
"p-2",
"p-4",
"bg-blue-300", "bg-blue-300",
"bg-orange-300", "bg-orange-300",
"bg-fuchsia-300", "bg-fuchsia-300",
"bg-green-400", "bg-green-400",
"bg-purple-400", "bg-purple-400",
"bg-orange-400", "bg-orange-400",
"bg-blue-600",
"bg-rose-600",
"alert-success",
"alert-warning",
"min-w-64",
"rotate-180",
"-rotate-180",
"transition-opacity",
"ease-in",
"duration-700",
"opacity-100",
"table-row",
"max-w-[60ch]",
"truncate",
"text-wrap",
"rounded-sm",
"rounded-md"
], ],
theme: { theme: {
extend: {}, extend: {
animation: {
fade: 'fadeIn .3s ease-in-out',
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 },
},
},
},
}, },
plugins: [], plugins: [],
} }

View File

@@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Welcome!{% endblock %}</title> <title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %} {% block stylesheets %}

View File

@@ -2,6 +2,7 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Welcome!{% endblock %}</title> <title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %} {% block stylesheets %}
@@ -13,13 +14,13 @@
{% endblock %} {% endblock %}
</head> </head>
<body class="flex flex-col bg-stone-700"> <body class="flex flex-col bg-stone-700">
<div class="grid grid-cols-6"> <div class="grid md:grid-cols-6">
<div class="col-span-1 h-screen"> <div class="hidden md:block md:col-span-1 md:h-screen">
<twig:NavBar /> <twig:NavBar />
</div> </div>
<div class="col-span-5 h-screen overflow-y-scroll"> <div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<twig:Header /> <twig:Header />
<h2 class="px-4 my-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<turbo-stream action="prepend" target="alert_list"> <turbo-stream action="prepend" target="alert_list">
<template> <template>
<twig:Alert :title="title|default('')" :message="message" :alert_id="alert_id" data-controller="alert" /> <twig:Alert :title="title|default('')" :message="message" :alert_id="alert_id" type="{{ type|default('success') }}" data-controller="alert" />
</template> </template>
</turbo-stream> </turbo-stream>

View File

@@ -5,7 +5,7 @@
<turbo-stream action="append" target="active_downloads"> <turbo-stream action="append" target="active_downloads">
<template> <template>
<twig:DownloadListRow download="{{ entity }}" isBroadcasted="true" /> <twig:DownloadListRow download="{{ entity }}" />
</template> </template>
</turbo-stream> </turbo-stream>
{% endblock %} {% endblock %}
@@ -14,10 +14,31 @@
{% if entity.status != "Complete" %} {% if entity.status != "Complete" %}
<turbo-stream action="update" target="download_progress_{{ id }}"> <turbo-stream action="update" target="download_progress_{{ id }}">
<template> <template>
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5" <div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width:{{ entity.progress }}%"> style="width:{{ entity.progress }}%">
<span>{{ entity.progress }}%</span>
</div> </div>
<div class="absolute text-black text-center"
style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">
{{ entity.progress }}%
</div>
</div>
</template>
</turbo-stream>
<turbo-stream action="update" target="action_buttons_{{ id }}">
<template>
{% if entity.status == "In Progress" and entity.progress < 100 %}
<button id="pause_{{ entity.id }}" class="text-orange-500 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: entity.id}) }}>
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />
</button>
{% elseif entity.status == "Paused" %}
<button id="resume_{{ entity.id }}" class="text-orange-500 mr-1 self-start" {{ stimulus_action('download_list', 'resumeDownload', 'click', {id: entity.id}) }}>
<twig:ux:icon name="icon-park-twotone:play" width="16.75px" height="16.75px" class="rounded-full" />
</button>
{% endif %}
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '17.5px', class: 'rounded-full align-middle text-red-600' }) %}
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: entity.id}) }}" show_cancel show_submit>
Are you sure you want to delete <span class="font-bold">{{ entity.filename }}</span>?
</twig:Modal>
</template> </template>
</turbo-stream> </turbo-stream>
{% else %} {% else %}
@@ -27,6 +48,9 @@
<turbo-stream action="remove" target="ad_download_{{ id }}"> <turbo-stream action="remove" target="ad_download_{{ id }}">
</turbo-stream> </turbo-stream>
<turbo-stream action="remove" target="action_buttons_{{ id }}">
</turbo-stream>
<turbo-stream action="prepend" target="alert_list"> <turbo-stream action="prepend" target="alert_list">
<template> <template>
<twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" /> <twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" />
@@ -35,7 +59,7 @@
<turbo-stream action="prepend" target="complete_downloads"> <turbo-stream action="prepend" target="complete_downloads">
<template> <template>
<twig:DownloadListRow download="{{ entity }}" :isBroadcasted="true" /> <twig:DownloadListRow download="{{ entity }}" />
</template> </template>
</turbo-stream> </turbo-stream>
{% endif %} {% endif %}

View File

@@ -1,7 +1,5 @@
<li {{ attributes }} id="alert_{{ alert_id }}" class=" <li {{ attributes }} id="alert_{{ alert_id }}"
text-white bg-green-950 text-sm min-w-[250px] class="alert alert-{{ type|default('success') }}"
hover:bg-green-900 border border-green-500 px-4 py-3
rounded-md z-40"
role="alert" role="alert"
> >
<div class="flex items-center"> <div class="flex items-center">

Some files were not shown because too many files have changed in this diff Show More