Compare commits

..

1 Commits

Author SHA1 Message Date
5ca0cd651d wip: toggling visibility of newly added download 2025-06-04 22:12:06 -05:00
156 changed files with 674 additions and 4583 deletions

View File

@@ -1,9 +1,8 @@
FROM dunglas/frankenphp:php8.4 FROM dunglas/frankenphp:php8.4-alpine
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 \
@@ -12,8 +11,8 @@ RUN install-php-extensions \
zip \ zip \
opcache opcache
RUN apt update && apt install -y wget RUN apk add --no-cache 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 --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

7
assets/bootstrap.js vendored
View File

@@ -1,10 +1,5 @@
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('popover', Popover); // app.register('some_controller_name', SomeImportedController);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);

View File

@@ -1,16 +1,5 @@
{ {
"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

@@ -1,46 +0,0 @@
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 {
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)
}
// 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)
}
default() {
console.log('Looks like you need to add an action to your action button...')
}
monitorDispatch() {
fetch('/api/monitor/dispatch')
}
}

View File

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

View File

@@ -1,43 +0,0 @@
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)
}
toggleMenu() {
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

@@ -8,7 +8,7 @@ import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
static targets = ['button', 'options'] static targets = ['button', 'options']
static outlets = ['result-filter', 'dialog'] static outlets = ['result-filter']
static values = { static values = {
tmdbId: String, tmdbId: String,
imdbId: String, imdbId: String,
@@ -54,9 +54,6 @@ export default class extends Controller {
title: this.titleValue, title: this.titleValue,
monitorType: 'tvshows', monitorType: 'tvshows',
}); });
if (this.hasDialogOutlet) {
this.dialogOutlet.close();
}
} }
async monitorSeason() { async monitorSeason() {

View File

@@ -16,7 +16,6 @@ export default class extends Controller {
}; };
static targets = ['list'] static targets = ['list']
static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false optionsLoaded = false
@@ -34,8 +33,6 @@ export default class extends Controller {
this.element.innerHTML = response; this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr'); this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon();
}); });
} }
} }
@@ -55,12 +52,10 @@ export default class extends Controller {
"resolution": option.querySelector('#resolution').textContent.trim(), "resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(), "codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(), "provider": option.querySelector('#provider').textContent.trim(),
"quality": option.dataset['quality'],
"languages": JSON.parse(option.dataset['languages']), "languages": JSON.parse(option.dataset['languages']),
} }
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;
@@ -86,7 +81,6 @@ 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

@@ -10,21 +10,16 @@ export default class extends Controller {
activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50"; activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50";
connect() { connect() {
this.element.querySelectorAll('.nav-list a:not(.nav-foot)').forEach(link => { console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles; link.className = this.inactiveStyles;
if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) { if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) {
link.className = this.activeStyles; link.className = this.activeStyles;
} }
}); });
window.addEventListener("resize", (event) => {
});
} }
toggle() { setActive() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
} }
} }

View File

@@ -11,7 +11,6 @@ export default class extends Controller {
languages = [] languages = []
providers = [] providers = []
qualities = []
seasons = [] seasons = []
activeFilter = { activeFilter = {
@@ -19,16 +18,13 @@ export default class extends Controller {
"codec": "", "codec": "",
"language": "", "language": "",
"provider": "", "provider": "",
"quality": "",
} }
static outlets = ['movie-results', 'tv-results', 'tv-episode-list'] static outlets = ['movie-results', 'tv-results']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
static values = { static values = {
'imdbId': String,
'media-type': String, 'media-type': String,
'episodes': Array, 'episodes': Array,
'reverseMappedQualities': Object,
} }
async connect() { async connect() {
@@ -38,12 +34,21 @@ export default class extends Controller {
await this.filter(); await this.filter();
} }
// Event is fired from movies/tvshows controllers to populate this data async movieResultsOutletConnected(outlet) {
async loadOptions({detail: { options }}) { await this.parseDownloadOptionForFilter(outlet)
await options.forEach((option) => { }
async tvResultsOutletConnected(outlet) {
await this.parseDownloadOptionForFilter(outlet)
}
async parseDownloadOptionForFilter(outlet) {
if (outlet.options.length === 0) {
await outlet.setOptions();
}
outlet.options.forEach((option) => {
this.addLanguages(option, option.dataset); this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset); this.addProviders(option, option.dataset);
this.addQualities(option, option.dataset);
}) })
await this.filter(); await this.filter();
} }
@@ -100,32 +105,6 @@ export default class extends Controller {
} }
addQualities(option, props) {
if (!this.qualities.includes(props['quality'])) {
if (props['quality'].toLowerCase() in this.reverseMappedQualitiesValue) {
this.qualities.push(props['quality']);
}
}
const preferred = this.qualityTarget.dataset.preferred;
if (preferred) {
this.qualityTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.qualityTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.qualityTarget.innerHTML = '<option value="">n/a</option>';
}
this.qualityTarget.innerHTML += this.qualities.sort()
.map((quality) => {
const preferred = this.qualityTarget.dataset.preferred;
if (preferred === quality) {
return;
}
return '<option value="' + quality + '">' + quality + '</option>'
})
.join();
}
async filter() { async filter() {
const currentSeason = this.activeFilter['season']; const currentSeason = this.activeFilter['season'];
@@ -135,7 +114,6 @@ export default class extends Controller {
"codec": this.codecTarget.value, "codec": this.codecTarget.value,
"language": this.languageTarget.value, "language": this.languageTarget.value,
"provider": this.providerTarget.value, "provider": this.providerTarget.value,
"quality": this.qualityTarget.value,
} }
if ("movies" === this.mediaTypeValue) { if ("movies" === this.mediaTypeValue) {
@@ -149,22 +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;
} }
downloadSeason() {
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
headers: {
'Content-Type': 'application/json'
}
})
}
selectAllEpisodes() { selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => { this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) { if (episode.isActive()) {

View File

@@ -1,59 +0,0 @@
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

@@ -1,51 +0,0 @@
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,44 +18,32 @@ export default class extends Controller {
active: Boolean, active: Boolean,
}; };
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer'] static targets = ['list', 'count', 'episodeSelector']
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 (this.optionsLoaded === false) { if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true; this.optionsLoaded = true;
let response; await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
try { .then(response => {
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`) this.element.innerHTML = response;
} catch (error) { this.options = this.element.querySelectorAll('tbody tr');
console.log('There was an error', error); 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;
if (response?.ok) { } else {
response = await response.text() this.episodeSelectorTarget.disabled = true;
this.listContainerTarget.innerHTML = response; }
this.options = this.element.querySelectorAll('tbody tr'); this.loadingIconOutlet.increaseCount();
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.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
} }
} }
@@ -67,13 +55,19 @@ 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() {
@@ -91,10 +85,7 @@ 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() {
@@ -136,7 +127,6 @@ 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;
@@ -162,7 +152,6 @@ 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

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 407 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 392 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 517 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 333 B

View File

@@ -10,115 +10,4 @@
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 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,12 +18,10 @@ 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:
@@ -36,11 +34,8 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
- $PWD/var/download:/var/download
tty: true tty: true
environment: command: php /app/bin/console messenger:consume async -vv --time-limit=3600 --limit=10
TZ: America/Chicago
command: php /app/bin/console messenger:consume async -vv --time-limit=3600
scheduler: scheduler:
@@ -48,8 +43,6 @@ 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
@@ -60,8 +53,6 @@ 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:
@@ -71,7 +62,6 @@ 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,29 +4,25 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.4", "php": ">=8.2",
"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.*",
@@ -44,7 +40,6 @@
"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",
@@ -60,9 +55,6 @@
"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
}, },

1285
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,4 @@ 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: '%env(REDIS_HOST)%' handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
#esi: true #esi: true
#fragments: true #fragments: true

View File

@@ -10,22 +10,10 @@ 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
@@ -37,12 +25,11 @@ framework:
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async 'App\Download\Action\Command\DownloadMediaCommand': async
'App\Download\Action\Command\DownloadSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async 'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
'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': media_cache 'App\Torrentio\Action\Command\GetTvShowOptionsCommand': async
# when@test: # when@test:
# framework: # framework:

View File

@@ -1,7 +0,0 @@
# 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,10 +1,5 @@
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

@@ -1,15 +1,7 @@
controllersBase: controllersIndex:
resource: resource:
path: ../src/Base/Framework/Controller/ path: ../src/Controller/
namespace: App\Base\Framework\Controller namespace: App\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersSearch:
resource:
path: ../src/Search/Framework/Controller/
namespace: App\Search\Framework\Controller
type: attribute type: attribute
defaults: defaults:
schemes: [ 'https' ] schemes: [ 'https' ]
@@ -37,19 +29,3 @@ 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

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

View File

@@ -4,20 +4,10 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# 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:
# App
app.url: '%env(APP_URL)%'
# Debrid Services
app.debrid.real_debrid.key: '%env(REAL_DEBRID_KEY)%'
# TMDB Key
app.meta_provider.tmdb.key: '%env(TMDB_API)%'
# 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: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%' media.movies_path: '%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
@@ -30,12 +20,6 @@ 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,7 +6,6 @@ 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,9 +4,6 @@ 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,9 +4,6 @@ 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,9 +4,6 @@ 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,6 +3,8 @@
frankenphp { frankenphp {
{$FRANKENPHP_CONFIG} {$FRANKENPHP_CONFIG}
} }
} }

View File

@@ -1,12 +0,0 @@
[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

@@ -3,7 +3,7 @@
# or pass your certificates into the 'app' container. # or pass your certificates into the 'app' container.
# Please omit any trailing slashes. The APP_URL is # Please omit any trailing slashes. The APP_URL is
# used to generate the Mercure URL behind the scenes. # used to generate the Mercure URL behind the scenes.
APP_URL="https://dev.caldwell.digital" APP_URL="https://torsearch.idocode.io"
APP_SECRET="70169beadfbc8101c393cbfbba27a313" APP_SECRET="70169beadfbc8101c393cbfbba27a313"
APP_ENV=prod APP_ENV=prod
@@ -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="" REAL_DEBRID_KEY="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ"
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw

View File

@@ -1,16 +1,4 @@
services: services:
caddy:
image: caddy:2.9.1
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- $PWD/../../bash/caddy:/etc/caddy
- $PWD/../../bash/certs:/etc/ssl
# The "entrypoint" into the application. This reverse proxy # The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not # proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates # running behind a reverse proxy inject your SSL certificates
@@ -23,13 +11,6 @@ services:
- '8006:80' - '8006:80'
env_file: env_file:
- .env - .env
volumes:
- ./downloads/movies:/var/download/movies
- ./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
@@ -44,10 +25,8 @@ services:
worker: worker:
image: code.caldwell.digital/home/torsearch-worker:latest image: code.caldwell.digital/home/torsearch-worker:latest
volumes: volumes:
- ./downloads/movies:/var/download/movies - /mnt/media/downloads/movies:/var/download/movies
- ./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
@@ -64,17 +43,35 @@ services:
scheduler: scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes: volumes:
- ./downloads/movies:/var/download/movies - ./downloads:/var/download
- ./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,40 +28,4 @@ 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

@@ -1,35 +0,0 @@
<?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

@@ -1,50 +0,0 @@
<?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

@@ -1,47 +0,0 @@
<?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 Version20250708033046 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'
DROP TABLE sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD only_future TINYINT(1) NOT NULL DEFAULT 1
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP only_future
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

@@ -1,49 +0,0 @@
<?php
namespace App\Base;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class ConfigResolver
{
private array $messages = [];
public function __construct(
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
#[Autowire(param: 'app.meta_provider.tmdb.key')]
private readonly ?string $tmdbApiKey = null,
#[Autowire(param: 'media.movies_path')]
private readonly ?string $moviesPath = null,
#[Autowire(param: 'media.tvshows.path')]
private readonly ?string $tvshowsPath = null,
) {}
public function validate(): bool
{
$valid = true;
if (null === $this->realDebridApiKey || "" === $this->realDebridApiKey) {
$this->messages[] = "Your Real Debrid API key is missing. Please set it to the 'REAL_DEBRID_KEY' environment variable.";
$valid = false;
}
if (null === $this->tmdbApiKey || "" === $this->tmdbApiKey) {
$this->messages[] = "Your TMDB API key is missing. Please set it to the 'TMDB_API' environment variable.";
$valid = false;
}
return $valid;
}
public function getMessages(): array
{
return $this->messages;
}
}

View File

@@ -1,115 +0,0 @@
<?php
namespace App\Base\Util;
class QualityList
{
public static $qualities = [
"dvd-rip" => [
"dvdrip",
"dvdmux",
"dvdr",
"dvd-full",
"full-rip",
"iso rip",
"lossless rip",
"untouched rip",
"dvd-5",
"dvd-9",
],
"hdtv, pdtv or dsrip" => [
"dsr",
"dsrip",
"satrip",
"dthrip",
"dvbrip",
"hdtv",
"pdtv",
"dtvrip",
"tvrip",
"hdtvrip",
],
"vodrip" => [
"vodrip",
"vodr",
],
"hc hd-rip" => [
"hc",
"hd-rip",
],
"webcap" => [
"web-cap",
"webcap",
"web cap",
],
"hdrip" => [
"hdrip",
"web-dlrip",
],
"webrip" => [
"webrip",
"web rip",
"web-rip",
"webrip (p2p)",
"web rip (p2p)",
"web-rip (p2p)",
],
"web-dl" => [
"webdl",
"web dl",
"web-dl",
"web (scene)",
"webrip",
],
"blu-ray/bd/brrip" => [
"blu-ray",
"bluray",
"bluray",
"bdrip",
"brip",
"brrip",
"bdr[13]",
"bd25",
"bd50",
"bd66",
"bd100",
"bd5",
"bd9",
"bdmv",
"bdiso",
"complete.bluray",
],
"4k" => [
"cbr",
"vbr",
],
];
public static function getQualities(): array
{
return self::$qualities;
}
public static function getBaseQualities(): array
{
return array_keys(self::$qualities);
}
public static function getBaseQualityFromSubQuality(string $key): ?string
{
return array_search($key, self::$qualities) ?? null;
}
public static function getAsReverseMap(): array
{
$results = [];
foreach (self::$qualities as $baseQualtiy => $subQualities) {
foreach ($subQualities as $subQuality) {
$results[$subQuality] = $baseQualtiy;
}
}
return $results;
}
}

View File

@@ -1,11 +1,12 @@
<?php <?php
namespace App\Base\Framework\Command; namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;

View File

@@ -1,12 +1,9 @@
<?php <?php
namespace App\Base\Framework\Command; namespace App\Command;
use App\User\Framework\Entity\Preference;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferenceOptionRepository; use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -21,17 +18,14 @@ class SeedDatabaseCommand extends Command
{ {
private PreferencesRepository $preferenceRepository; private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository; private PreferenceOptionRepository $preferenceOptionRepository;
private UserRepository $userRepository;
public function __construct( public function __construct(
PreferencesRepository $preferenceRepository, PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository, PreferenceOptionRepository $preferenceOptionRepository,
UserRepository $userRepository,
) { ) {
parent::__construct(); parent::__construct();
$this->preferenceRepository = $preferenceRepository; $this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository; $this->preferenceOptionRepository = $preferenceOptionRepository;
$this->userRepository = $userRepository;
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
@@ -40,7 +34,6 @@ class SeedDatabaseCommand extends Command
$this->seedPreferences($io); $this->seedPreferences($io);
$this->seedPreferenceOptions($io); $this->seedPreferenceOptions($io);
$this->updateUserPreferences($io);
return Command::SUCCESS; return Command::SUCCESS;
} }
@@ -51,48 +44,22 @@ class SeedDatabaseCommand extends Command
$preferences = $this->getPreferences(); $preferences = $this->getPreferences();
foreach ($preferences as $preference) { foreach ($preferences as $preference) {
$isNewRecord = false; if ($this->preferenceRepository->find($preference['id'])) {
$preferenceRecord = $this->preferenceRepository->findOneBy(['id' => $preference['id']]); continue;
if (null === $preferenceRecord) {
$isNewRecord = true;
$preferenceRecord = new Preference();
} }
$preferenceRecord $this->preferenceRepository->getEntityManager()->persist((new \App\User\Framework\Entity\Preference())
->setId($preference['id']) ->setId($preference['id'])
->setName($preference['name']) ->setName($preference['name'])
->setDescription($preference['description']) ->setDescription($preference['description'])
->setEnabled($preference['enabled']) ->setEnabled($preference['enabled'])
->setType($preference['type']); ->setType($preference['type'])
);
if (true === $isNewRecord) {
$this->preferenceRepository->getEntityManager()->persist($preferenceRecord);
}
} }
$this->preferenceRepository->getEntityManager()->flush(); $this->preferenceRepository->getEntityManager()->flush();
} }
private function updateUserPreferences(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Updating user preferences...');
$users = $this->userRepository->findAll();
$preferences = $this->preferenceRepository->findAll();
foreach ($users as $user) {
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference(
new UserPreference()
->setPreference($preference)
->setUser($user)
->setPreferenceValue(null)
);
}
}
}
$this->userRepository->getEntityManager()->flush();
}
private function getPreferences(): array private function getPreferences(): array
{ {
return [ return [
@@ -124,13 +91,6 @@ class SeedDatabaseCommand extends Command
'enabled' => true, 'enabled' => true,
'type' => 'media', 'type' => 'media',
], ],
[
'id' => 'quality',
'name' => 'Quality',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[ [
'id' => 'movie_folder', 'id' => 'movie_folder',
'name' => 'Create new folder for Movies', 'name' => 'Create new folder for Movies',

View File

@@ -1,11 +1,13 @@
<?php <?php
namespace App\Base\Framework\Command; namespace App\Command;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;

View File

@@ -1,8 +1,8 @@
<?php <?php
namespace App\Base\Framework\Controller; namespace App\Controller;
use App\Base\Util\Broadcaster; use App\Util\Broadcaster;
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\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;

View File

@@ -1,10 +1,9 @@
<?php <?php
namespace App\Base\Framework\Controller; namespace App\Controller;
use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Download\Framework\Repository\DownloadRepository;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\User\Framework\Entity\User;
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;
@@ -13,15 +12,15 @@ 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
{ {
/** @var User $user */ $request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
$user = $this->getUser();
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

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Search\Framework\Controller; namespace App\Controller;
use App\Search\Action\Handler\GetMediaInfoHandler; use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler; use App\Search\Action\Handler\SearchHandler;
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class WebController extends AbstractController final class SearchController extends AbstractController
{ {
public function __construct( public function __construct(
private SearchHandler $searchHandler, private SearchHandler $searchHandler,
@@ -33,12 +33,12 @@ final class WebController extends AbstractController
]); ]);
} }
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')] #[Route('/result/{mediaType}/{tmdbId}', 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

@@ -1,13 +1,12 @@
<?php <?php
namespace App\Torrentio\Framework\Controller; namespace App\Controller;
use App\Base\Util\Broadcaster;
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 Carbon\Carbon; use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -16,7 +15,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 WebController extends AbstractController final class TorrentioController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler, private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
@@ -53,24 +52,13 @@ final class WebController extends AbstractController
$input->episode, $input->episode,
); );
try { return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) { $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $results = $this->getTvShowOptionsHandler->handle($input->toCommand());
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); return $this->render('torrentio/tvshows.html.twig', [
return $this->render('torrentio/tvshows.html.twig', [ 'results' => $results,
'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

@@ -1,18 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DownloadSeasonCommand>
*/
class DownloadSeasonCommand implements CommandInterface
{
public function __construct(
public int $userId,
public int $season,
public string $imdbId,
public string $mediaType = 'tvshows',
) {}
}

View File

@@ -1,15 +0,0 @@
<?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

@@ -1,15 +0,0 @@
<?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,9 +38,7 @@ readonly class DownloadMediaHandler implements HandlerInterface
} }
try { try {
if ($download->getStatus() !== 'Paused') { $this->downloadRepository->updateStatus($download->getId(), 'In Progress');
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
}
$this->downloader->download( $this->downloader->download(
$command->mediaType, $command->mediaType,
@@ -49,9 +47,7 @@ readonly class DownloadMediaHandler implements HandlerInterface
$download->getId() $download->getId()
); );
if ($download->getStatus() !== 'Paused') { $this->downloadRepository->updateStatus($download->getId(), 'Complete');
$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

@@ -1,111 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Handler\MonitorTvEpisodeHandler;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Repository\UserRepository;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DownloadSeasonCommand, DownloadMediaResult> */
readonly class DownloadSeasonHandler implements HandlerInterface
{
public function __construct(
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private Tmdb $tmdb,
private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$series = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
$this->logger->info('> [DownloadTvSeasonHandler] Executing DownloadTvSeasonHandler for "' . $series->title . '" season ' . $command->season);
$episodesInSeason = Map::from($series->episodes[$command->season]);
$this->logger->info('> [DownloadTvSeasonHandler] ...Found ' . count($episodesInSeason) . ' episodes in season ' . $command->season);
$downloadCommands = [];
foreach ($episodesInSeason as $episode) {
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']);
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$series->tmdbId,
$command->imdbId,
$command->season,
$episode['episode_number']
)
);
$this->logger->info('> [DownloadTvSeasonHandler] ......Found ' . count($results->results) . ' total download options, beginning evaluation');
$userPreferences = UserPreferencesFactory::createFromUser(
$this->userRepository->findOneBy(['id' => $command->userId])
);
$result = $this->downloadOptionEvaluator->evaluateOptions($results->results, $userPreferences);
if (null !== $result) {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result');
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode['episode_number']);
$downloadCommand = new DownloadMediaCommand(
$result->url,
$series->title,
$result->filename,
'tvshows',
$command->imdbId,
$command->userId,
);
$this->bus->dispatch($downloadCommand);
$downloadCommands[] = $downloadCommand;
} else {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 0 matching results');
}
}
return new DownloadSeasonResult(
status: 200,
message: 'Success',
data: ['downloads' => $downloadCommands],
);
}
private function getDownloadedEpisodes(string $title)
{
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($title)
->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);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
}
}

View File

@@ -1,33 +0,0 @@
<?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

@@ -1,40 +0,0 @@
<?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,9 +26,6 @@ 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

@@ -1,37 +0,0 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DownloadSeasonInput> */
class DownloadSeasonInput implements InputInterface
{
public function __construct(
#[SourceRoute('imdbId')]
public string $imdbId,
#[SourceRoute('season')]
public int $season,
#[SourceRequest('mediaType')]
public string $mediaType = 'tvshows',
public ?int $userId = null,
) {}
public function toCommand(): CommandInterface
{
return new DownloadSeasonCommand(
$this->userId,
$this->season,
$this->imdbId,
$this->mediaType,
);
}
}

View File

@@ -1,24 +0,0 @@
<?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

@@ -1,24 +0,0 @@
<?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

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

View File

@@ -1,16 +0,0 @@
<?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

@@ -1,16 +0,0 @@
<?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

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

View File

@@ -5,21 +5,14 @@ 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,
) {} ) {}
/** /**
@@ -29,23 +22,16 @@ 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);
$processArgs = ['wget', $url]; $process = (new Process([
'wget',
if ($downloadEntity->getStatus() === 'Paused') { $url
$downloadEntity->setStatus('In Progress'); ]))->setWorkingDirectory($path);
$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
@@ -55,18 +41,7 @@ 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);
@@ -81,9 +56,7 @@ class ProcessDownloader implements DownloaderInterface
} }
fwrite(STDOUT, $buffer); fwrite(STDOUT, $buffer);
}); });
if ($downloadEntity->getStatus() !== 'Paused') { $downloadEntity->setProgress(100);
$downloadEntity->setProgress(100);
}
} catch (ProcessFailedException $exception) { } catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed'); $downloadEntity->setStatus('Failed');
} }

View File

@@ -2,19 +2,11 @@
namespace App\Download\Framework\Controller; namespace App\Download\Framework\Controller;
use App\Base\Util\Broadcaster;
use App\Download\Action\Handler\DeleteDownloadHandler; use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\DownloadSeasonHandler;
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\DownloadSeasonInput;
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\User\Dto\UserPreferencesFactory; 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;
@@ -32,13 +24,6 @@ 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,
@@ -80,46 +65,4 @@ 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']);
}
#[Route('/api/download/season/{imdbId}/{season}', name: 'api_download_season', methods: ['GET'])]
public function downloadSeason(
DownloadSeasonInput $input,
): Response {
$input->userId = $this->getUser()->getId();
$this->bus->dispatch($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "Your download for season $input->season has been added to the queue.",
);
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
}
} }

View File

@@ -5,16 +5,12 @@ 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]
@@ -166,16 +162,4 @@ 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

@@ -2,9 +2,9 @@
namespace App\Download\Framework\Repository; namespace App\Download\Framework\Repository;
use App\Base\Util\Paginator;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -25,31 +25,27 @@ class DownloadRepository extends ServiceEntityRepository
$this->paginator = $paginator; $this->paginator = $paginator;
} }
public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10, string $term = ""): Paginator public function getCompletePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 10): 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, string $term = ""): Paginator public function getActivePaginated(UserInterface $user, int $pageNumber = 1, int $perPage = 5): 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', 'Paused']) ->setParameter('statuses', ['New', 'In Progress'])
->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

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Enum; namespace App\Enum;
enum MediaType: string enum MediaType: string
{ {

View File

@@ -3,15 +3,12 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult; use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb; use App\Monitor\Service\MonitorOptionEvaluator;
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 App\User\Dto\UserPreferencesFactory;
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;
@@ -25,75 +22,56 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private GetTvShowOptionsHandler $getTvShowOptionsHandler, private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private DownloadOptionEvaluator $downloadOptionEvaluator, private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
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
{ {
try { $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode()); $monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode()); $this->logger->info('> [MonitorTvEpisodeHandler] Searching for "' . $monitor->getTitle() . '" season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' download options');
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) { $results = $this->getTvShowOptionsHandler->handle(
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now'); new GetTvShowOptionsCommand(
return new MonitorTvEpisodeResult( $monitor->getTmdbId(),
status: 'OK', $monitor->getImdbId(),
result: [ $monitor->getSeason(),
'message' => 'No change', $monitor->getEpisode()
'monitor' => $monitor, )
] );
);
}
$monitor->setStatus('In Progress'); $this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options');
$this->monitorRepository->getEntityManager()->flush();
$results = $this->getTvShowOptionsHandler->handle( $result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
new GetTvShowOptionsCommand(
$monitor->getTmdbId(),
$monitor->getImdbId(),
$monitor->getSeason(),
$monitor->getEpisode()
)
);
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found ' . count($results->results) . ' total download options, beginning evaluation'); if (null !== $result) {
$this->logger->info('> [MonitorTvEpisodeHandler] 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$result = $this->downloadOptionEvaluator->evaluateOptions($results->results, UserPreferencesFactory::createFromUser($monitor->getUser())); $this->bus->dispatch(new DownloadMediaCommand(
$result->url,
if (null !== $result) { $monitor->getTitle(),
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"'); $result->filename,
$this->bus->dispatch(new DownloadMediaCommand( 'tvshows',
$result->url, $monitor->getImdbId(),
$monitor->getTitle(), $monitor->getUser()->getId(),
$result->filename, ));
'tvshows', $monitor->setStatus('Complete');
$monitor->getImdbId(), $monitor->setDownloadedAt(new DateTimeImmutable());
$monitor->getUser()->getId(), } else {
)); $this->logger->info('> [MonitorTvEpisodeHandler] 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());
} else {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Active');
}
} catch (\Throwable $exception) {
$this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage());
$this->logger->error($exception->getMessage());
$monitor->setStatus('Active'); $monitor->setStatus('Active');
} }
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->monitorRepository->getEntityManager()->flush(); $this->entityManager->flush();
return new MonitorTvEpisodeResult( return new MonitorMovieResult(
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\Command\MonitorTvSeasonCommand; use App\Monitor\Action\Result\MonitorMovieResult;
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,14 +17,16 @@ 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<MonitorTvSeasonCommand> */ /** @implements HandlerInterface<MonitorMovieCommand> */
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,
@@ -39,42 +41,33 @@ 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())->episodes[$monitor->getSeason()] $this->tmdb->tvDetails($monitor->getTmdbId())
->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());
if ($downloadedEpisodes->count() !== $episodesInSeason->count()) { // Dispatch Episode commands for each missing Episode
// Dispatch Episode commands for each missing Episode foreach ($episodesInSeason as $episode) {
foreach ($episodesInSeason as $episode) { $monitorCheck = $this->monitorRepository->findOneBy([
// Check if the episode is already downloaded 'imdbId' => $monitor->getImdbId(),
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes); 'title' => $monitor->getTitle(),
$this->logger->info('> [MonitorTvSeasonHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO')); 'monitorType' => 'tvepisode',
if (true === $episodeExists) { 'season' => $monitor->getSeason(),
$this->logger->info('> [MonitorTvSeasonHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping'); 'episode' => $episode['episode_number'],
continue; 'status' => ['New', 'Active', 'In Progress']
} ]);
// Check for existing monitors $this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $monitor->getSeason() . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (null !== $monitorCheck ? 'YES' : 'NO'));
$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())
@@ -95,38 +88,16 @@ 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 MonitorTvSeasonResult( return new MonitorMovieResult(
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,12 +5,11 @@ 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\MonitorTvShowResult; use App\Monitor\Action\Result\MonitorTvEpisodeResult;
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;
@@ -18,6 +17,7 @@ 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,117 +39,55 @@ 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())->episodes $this->tmdb->tvDetails($monitor->getTmdbId())
->episodes
)->flat(1); )->flat(1);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle()); // Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
$episodeAlreadyDownloaded = $downloadedEpisodes->find(
fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number']
);
$episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded);
$episodeMonitors = []; if (false === $episodeAlreadyDownloaded) {
if ($downloadedEpisodes->count() !== $episodesInShow->count()) { $monitor = (new Monitor())
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
// Only monitor future episodes
$this->logger->info('> [MonitorTvShowHandler] Evaluating "' . $monitor->getTitle() . '", season "' . $episode['season_number'] . '" episode "' . $episode['episode_number'] . '"');
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue;
}
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] ...Episode exists? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue;
}
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Monitor exists? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] ...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('tvepisode') ->setMonitorType('tvshow')
->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($episodeMonitor); $this->monitorRepository->getEntityManager()->persist($monitor);
$this->monitorRepository->getEntityManager()->flush(); $this->monitorRepository->getEntityManager()->flush();
$episodeMonitors[] = $episodeMonitor; $command = new MonitorTvEpisodeCommand($monitor->getId());
$this->bus->dispatch($command);
// Immediately run the monitor $this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle());
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvShowHandler] ...Dispatching MonitorTvEpisodeCommand');
} }
} }
// 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 MonitorTvShowResult( return new MonitorTvEpisodeResult(
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

@@ -1,13 +0,0 @@
<?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

@@ -1,13 +0,0 @@
<?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

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

View File

@@ -2,15 +2,17 @@
namespace App\Monitor\Framework\Controller; namespace App\Monitor\Framework\Controller;
use App\Base\Util\Broadcaster; 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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Bundle\SecurityBundle\Security;
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
@@ -44,25 +46,22 @@ 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, Broadcaster $broadcaster): Response
{
$dispatcher();
$broadcaster->alert('Success', 'The monitor job has been dispatched.');
return $this->json([
'status' => 200,
'message' => 'Manually dispatched MonitorDispatcher'
]);
}
} }

View File

@@ -4,9 +4,6 @@ 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;
@@ -50,9 +47,6 @@ class Monitor
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $searchCount = null; private ?int $searchCount = null;
#[ORM\Column]
private bool $onlyFuture = true;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null; private ?\DateTimeInterface $lastSearch = null;
@@ -62,17 +56,6 @@ 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;
@@ -150,14 +133,9 @@ class Monitor
return $this; return $this;
} }
public function isOnlyFuture(): bool
{
return $this->onlyFuture;
}
public function getLastSearch(): ?\DateTimeInterface public function getLastSearch(): ?\DateTimeInterface
{ {
return Carbon::parse($this->lastSearch); return $this->lastSearch;
} }
public function setLastSearch(?\DateTimeInterface $lastSearch): static public function setLastSearch(?\DateTimeInterface $lastSearch): static
@@ -226,51 +204,4 @@ 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

@@ -2,8 +2,8 @@
namespace App\Monitor\Framework\Repository; namespace App\Monitor\Framework\Repository;
use App\Base\Util\Paginator;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
@@ -21,7 +21,7 @@ class MonitorRepository extends ServiceEntityRepository
$this->paginator = $paginator; $this->paginator = $paginator;
} }
public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage, string $searchTerm): Paginator public function getUserMonitorsPaginated(UserInterface $user, int $page, int $perPage): Paginator
{ {
$query = $this->createQueryBuilder('m') $query = $this->createQueryBuilder('m')
->andWhere('m.status IN (:statuses)') ->andWhere('m.status IN (:statuses)')

View File

@@ -7,12 +7,11 @@ 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('0 * * * *', schedule: 'monitor')] #[AsCronTask('* * * * *', schedule: 'monitor')]
class MonitorDispatcher class MonitorDispatcher
{ {
public function __construct( public function __construct(
@@ -24,8 +23,6 @@ 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,
@@ -44,18 +41,4 @@ 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,19 +3,14 @@
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;
@@ -23,9 +18,6 @@ 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,
@@ -35,7 +27,6 @@ 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;
@@ -52,11 +43,6 @@ 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;
@@ -139,85 +125,4 @@ 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,8 +8,7 @@ use OneToMany\RichBundle\Contract\CommandInterface;
class GetMediaInfoCommand implements CommandInterface class GetMediaInfoCommand implements CommandInterface
{ {
public function __construct( public function __construct(
public string $imdbId, public string $tmdbId,
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->imdbId, $command->mediaType); $media = $this->tmdb->mediaDetails($command->tmdbId, $command->mediaType);
return new GetMediaInfoResult($media, $command->season); return new GetMediaInfoResult($media);
} }
} }

View File

@@ -2,6 +2,7 @@
namespace App\Search\Action\Input; namespace App\Search\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
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;
@@ -11,21 +12,15 @@ use OneToMany\RichBundle\Contract\InputInterface;
class GetMediaInfoInput implements InputInterface class GetMediaInfoInput implements InputInterface
{ {
public function __construct( public function __construct(
#[SourceRoute('imdbId')] #[SourceRoute('tmdbId')]
public string $imdbId, public string $tmdbId,
#[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
{ {
if ("tvshows" === $this->mediaType && null === $this->season) { return new GetMediaInfoCommand($this->tmdbId, $this->mediaType);
$this->season = 1;
}
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season);
} }
} }

View File

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

View File

@@ -1,65 +0,0 @@
<?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

@@ -1,38 +0,0 @@
<?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,21 +2,20 @@
namespace App\Tmdb; namespace App\Tmdb;
use Aimeos\Map; use App\Enum\MediaType;
use App\Base\Enum\MediaType;
use App\ValueObject\ResultFactory; use App\ValueObject\ResultFactory;
use Psr\Cache\CacheItemPoolInterface;
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\ItemInterface; 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;
use Tmdb\Event\Listener\Request\UserAgentRequestListener; use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\Listener\RequestListener;
use Tmdb\Event\RequestEvent; use Tmdb\Event\RequestEvent;
use Tmdb\Model\Movie; use Tmdb\Model\Movie;
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery; use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
@@ -40,7 +39,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 CacheItemPoolInterface $cache, private readonly CacheInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
) { ) {
@@ -71,13 +70,7 @@ 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 Psr6CachedRequestListener( $requestListener = new RequestListener($this->client->getHttpClient(), $this->eventDispatcher);
$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());
@@ -104,16 +97,6 @@ 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) {
@@ -131,16 +114,6 @@ 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) {
@@ -160,12 +133,9 @@ 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;
} }
@@ -173,16 +143,7 @@ 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)
@@ -215,20 +176,13 @@ class Tmdb
continue; continue;
} }
$series['episodes'][$season['season_number']] = Map::from( $series['episodes'][$season['season_number']] = $client->getApi()->getSeason($series['id'], $season['season_number'])['episodes'];
$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,7 +2,6 @@
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;
@@ -15,15 +14,12 @@ 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: $media, media: $this->tmdb->mediaDetails($command->tmdbId, 'movies'),
file: $this->mediaFiles->movieExists($media->title),
results: $this->torrentio->search($command->imdbId, 'movies'), results: $this->torrentio->search($command->imdbId, 'movies'),
); );
} }

View File

@@ -2,7 +2,6 @@
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;
@@ -17,18 +16,12 @@ 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: $media, media: $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode),
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,7 +9,6 @@ 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,13 +4,11 @@ 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,99 +6,63 @@ 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'; 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 $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 TagAwareCacheInterface $cache, private CacheInterface $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, bool $parseResults = true): array public function search(string $imdbCode, string $type, array $filter = []): array
{ {
$cacheKey = "torrentio.{$imdbCode}"; $cacheKey = "torrentio.{$imdbCode}";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) { $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['torrentio', $type, $imdbCode]); $response = file_get_contents(str_replace('{imdbCode}', $imdbCode, $this->searchUrl));
try { return json_decode(
$response = $this->client->get("$this->searchUrl/$imdbCode.json"); $response,
return json_decode( true
$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 [];
}); });
if (true === $parseResults) { return $this->parse($results, $filter);
return $this->parse($results);
}
return $results;
} }
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array public function fetchEpisodeResults(string $imdbId, int $season, int $episode): 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));
$item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]); $response = file_get_contents(str_replace('{imdbCode}', "$imdbId:$season:$episode", $this->searchUrl));
try { return json_decode(
$response = $this->client->get("$this->searchUrl/$imdbId:$season:$episode.json"); $response,
return json_decode( true
$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 [];
}); });
if (null === $results) { return $this->parse($results, []);
throw new TorrentioRateLimitException();
}
if (true === $parseResults) {
return $this->parse($results);
}
return $results;
} }
public function parse(array $data): array public function parse(array $data, array $filter): array
{ {
$ruleEngine = new RuleEngine();
foreach ($filter as $rule => $value) {
if ('resolution' === $rule) {
$ruleEngine->addRule(new Resolution($value));
}
}
$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")) {
@@ -120,7 +84,9 @@ class Torrentio
$bingeGroup $bingeGroup
); );
$results[] = $result; if ($ruleEngine->validateAll($result)) {
$results[] = $result;
}
} }
return $results; return $results;

View File

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

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Client\Torrentio;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ApiController extends AbstractController
{
public function __construct(
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),
);
}
}

View File

@@ -2,7 +2,8 @@
namespace App\Torrentio\Result; namespace App\Torrentio\Result;
use App\Base\Util\CountryLanguages; use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use Nihilarr\PTN; use Nihilarr\PTN;
class ResultFactory class ResultFactory
@@ -21,7 +22,6 @@ class ResultFactory
string $bingeGroup = "-" string $bingeGroup = "-"
) { ) {
$ptn = (object) (new PTN())->parse($title); $ptn = (object) (new PTN())->parse($title);
// dump($ptn);
return new TorrentioResult( return new TorrentioResult(
self::trimTitle($title), self::trimTitle($title),
urldecode($url), urldecode($url),
@@ -34,7 +34,6 @@ class ResultFactory
$bingeGroup, $bingeGroup,
$ptn->resolution ?? "-", $ptn->resolution ?? "-",
self::setCodec($ptn->codec ?? "-"), self::setCodec($ptn->codec ?? "-"),
$ptn->quality ?? "-",
$ptn, $ptn,
substr(base64_encode($url), strlen($url) - 10), substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-", $ptn->episode ?? "-",

View File

@@ -16,7 +16,6 @@ class TorrentioResult
public ?string $bingeGroup = "-", public ?string $bingeGroup = "-",
public ?string $resolution = "-", public ?string $resolution = "-",
public ?string $codec = "-", public ?string $codec = "-",
public ?string $quality = "-",
public object|array $ptn = [], public object|array $ptn = [],
public ?string $key = "-", public ?string $key = "-",
public ?string $episodeNumber = "-", public ?string $episodeNumber = "-",

View File

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

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