Compare commits

..

1 Commits

Author SHA1 Message Date
5ca0cd651d wip: toggling visibility of newly added download 2025-06-04 22:12:06 -05:00
120 changed files with 433 additions and 3361 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,30 +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() {}
connect() {}
disconnect() {}
async clearAll() {
let response = await fetch('/api/torrentio/cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
type: 'torrentio',
mediaType: 'tvshows',
})
});
response = await response.json()
}
}

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

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

View File

@@ -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,43 +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.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
} }
} }
@@ -66,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() {
@@ -90,16 +85,7 @@ export default class extends Controller {
} }
toggleList() { toggleList() {
// if (!this.isOpen) {
// this.toggleButtonTarget.classList.add('rotate-180');
// this.toggleButtonTarget.classList.remove('-rotate-180');
// } else {
// this.toggleButtonTarget.classList.add('-rotate-180');
// this.toggleButtonTarget.classList.remove('rotate-180');
// }
this.listTarget.classList.toggle('hidden'); this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90');
} }
download() { download() {

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

@@ -10,63 +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/70 hover:bg-yellow-600 border-yellow-400 text-black
}
.primary-btn {
@apply px-1 py-1 rounded-md bg-orange-500 self-end text-white w-16 ml-2 hover:bg-orange-600
}
.secondary-btn {
@apply px-1 py-1 rounded-md self-end w-16 hover:bg-stone-100
}
}
/* 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;
} }

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 media_cache -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.*",
@@ -59,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
}, },

1195
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,5 +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],
]; ];

View File

@@ -15,11 +15,5 @@ framework:
#app: cache.adapter.apcu #app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default # Namespaced pools use the above "app" backend by default
pools: #pools:
torrentio.cache: #my.dedicated.cache: null
adapter: cache.app
tmdb.cache:
adapter: cache.app
default_lifetime: 2592000
page.cache:
adapter: cache.app

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
@@ -41,7 +29,7 @@ framework:
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async 'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async 'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async 'App\Monitor\Action\Command\MonitorMovieCommand': async
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': 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

@@ -29,12 +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' ]

View File

@@ -5,10 +5,9 @@
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
# Media # Media
media.base_path: '/var/download'
media.default_movies_dir: movies media.default_movies_dir: movies
media.default_tvshows_dir: tvshows media.default_tvshows_dir: tvshows
media.movies_path: '/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
@@ -21,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

@@ -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

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

View File

@@ -28,16 +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',
],
]; ];

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

@@ -3,8 +3,6 @@
namespace App\Controller; namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -14,13 +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
{ {
$request->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->render('index/index.html.twig', [ return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(), 'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(), 'recent_downloads' => $this->getUser()->getDownloads(),
@@ -28,11 +28,4 @@ final class IndexController extends AbstractController
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6), 'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]); ]);
} }
#[Route('/test', name: 'app_test')]
public function test()
{
$result = $this->monitorTvShowHandler->handle(new MonitorTvShowCommand(355));
return $this->json($result);
}
} }

View File

@@ -33,12 +33,12 @@ final class SearchController 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

@@ -6,7 +6,6 @@ use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput; use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster; use App\Util\Broadcaster;
use Carbon\Carbon; use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,7 +14,6 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; 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;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class TorrentioController extends AbstractController final class TorrentioController extends AbstractController
{ {
@@ -26,7 +24,7 @@ final class TorrentioController extends AbstractController
) {} ) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')] #[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, TagAwareCacheInterface $pageCache): Response public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.movies.%s.%s", "page.torrentio.movies.%s.%s",
@@ -34,29 +32,17 @@ final class TorrentioController extends AbstractController
$input->imdbId $input->imdbId
); );
try { return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
return $pageCache->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->getMovieOptionsHandler->handle($input->toCommand());
$item->tag(['page', 'page.torrentio', 'page.torrentio.movies', "page.torrentio.movies.$input->tmdbId.$input->imdbId", 'torrentio', 'torrentio.movies', "torrentio.movies.$input->tmdbId.$input->imdbId"]); return $this->render('torrentio/movies.html.twig', [
$results = $this->getMovieOptionsHandler->handle($input->toCommand()); 'results' => $results,
return $this->render('torrentio/movies.html.twig', [ ]);
'results' => $results, });
]);
});
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
} }
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')] #[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, TagAwareCacheInterface $pageCache): Response public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s", "page.torrentio.tvshows.%s.%s.%s.%s",
@@ -66,25 +52,13 @@ final class TorrentioController extends AbstractController
$input->episode, $input->episode,
); );
try { return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
return $pageCache->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());
$item->tag(['page', 'page.torrentio', 'page.torrentio.tvshows', "page.torrentio.tvshows.$input->tmdbId.$input->imdbId", "page.torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season", "page.torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season.$input->episode", 'torrentio', 'torrentio.tvshows', "torrentio.tvshows.$input->tmdbId.$input->imdbId", "torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season", "torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season.$input->episode", $input->imdbId, $input->tmdbId]); return $this->render('torrentio/tvshows.html.twig', [
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); 'results' => $results,
return $this->render('torrentio/tvshows.html.twig', [ ]);
'results' => $results, });
]);
});
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
} }
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')] #[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]

View File

@@ -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,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,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,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

@@ -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

@@ -3,15 +3,10 @@
namespace App\Download\Framework\Controller; namespace App\Download\Framework\Controller;
use App\Download\Action\Handler\DeleteDownloadHandler; use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput; use App\Download\Action\Input\DeleteDownloadInput;
use App\Download\Action\Input\DownloadMediaInput; use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Broadcaster; use App\Util\Broadcaster;
use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -29,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,
@@ -77,32 +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']);
}
} }

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

@@ -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

@@ -5,13 +5,10 @@ namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult; use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator; use App\Monitor\Service\MonitorOptionEvaluator;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -30,26 +27,12 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
private MessageBusInterface $bus, private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
private MonitorRepository $monitorRepository, private MonitorRepository $monitorRepository,
private Tmdb $tmdb,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler'); $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::now()) {
$this->logger->info('> [MonitorTvEpisodeHandler] Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'message' => 'No change',
'monitor' => $monitor,
]
);
}
$monitor->setStatus('In Progress'); $monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush(); $this->monitorRepository->getEntityManager()->flush();
@@ -88,7 +71,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->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,116 +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
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping');
continue;
}
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping');
continue;
}
// Create the monitor
$episodeMonitor = (new Monitor())
->setParent($monitor)
->setUser($monitor->getUser()) ->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId()) ->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId()) ->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle()) ->setTitle($monitor->getTitle())
->setMonitorType('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 for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
} }
} }
// Set the status to Active, so it will be re-executed.
$monitor->setStatus('Active');
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush(); $this->entityManager->flush();
return new 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,13 +2,17 @@
namespace App\Monitor\Framework\Controller; namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Monitor\Action\Handler\AddMonitorHandler; use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler; use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput; use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Util\Broadcaster; use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\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
@@ -42,9 +46,19 @@ 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

View File

@@ -4,8 +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 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;
@@ -58,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;
@@ -217,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

@@ -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

@@ -11,7 +11,7 @@ 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(

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

@@ -3,7 +3,6 @@
namespace App\Search\Action\Input; namespace App\Search\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Enum\MediaType;
use App\Search\Action\Command\GetMediaInfoCommand; use App\Search\Action\Command\GetMediaInfoCommand;
use OneToMany\RichBundle\Attribute\SourceRoute; use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -13,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

@@ -2,12 +2,8 @@
namespace App\Tmdb; namespace App\Tmdb;
use Aimeos\Map;
use App\Enum\MediaType; use App\Enum\MediaType;
use App\ValueObject\ResultFactory; use App\ValueObject\ResultFactory;
use Carbon\Carbon;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -15,7 +11,6 @@ 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;
@@ -44,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 $tmdbCache, private readonly CacheInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
) { ) {
@@ -75,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,
$tmdbCache,
$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());
@@ -108,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) {
@@ -135,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) {
@@ -164,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;
} }
@@ -177,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)
@@ -219,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 {
@@ -325,7 +275,7 @@ class Tmdb
public function getImdbId(string $tmdbId, $mediaType) public function getImdbId(string $tmdbId, $mediaType)
{ {
$externalIds = $this->tmdbCache->get("tmdb.externalIds.{$tmdbId}", $externalIds = $this->cache->get("tmdb.externalIds.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) { function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) { switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value: case MediaType::Movie->value:
@@ -346,7 +296,7 @@ class Tmdb
public function getImages($tmdbId, $mediaType) public function getImages($tmdbId, $mediaType)
{ {
return $this->tmdbCache->get("tmdb.images.{$tmdbId}", return $this->cache->get("tmdb.images.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) { function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) { switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value: case MediaType::Movie->value:

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Torrentio\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class DeleteCacheCommand implements CommandInterface
{
public function __construct(
public ?string $type = null,
public ?string $mediaType = null,
public ?string $tmdbId = null,
public ?string $imdbId = null,
public ?int $season = null,
public ?int $episode = null,
public ?array $tags = null,
) {}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Torrentio\Action\Handler;
use Aimeos\Map;
use App\Torrentio\Action\Command\DeleteCacheCommand;
use App\Torrentio\Action\Result\DeleteCacheResult;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/** @implements HandlerInterface<DeleteCacheCommand, DeleteCacheResult> */
class DeleteCacheHandler implements HandlerInterface
{
public function __construct(
private readonly TagAwareCacheInterface $torrentioCache
) {}
public function handle(CommandInterface $command): ResultInterface
{
$input = Map::from((array) $command)
->filter(fn ($value, $key) => null !== $value && "" !== $value)
;
$cacheKey = null;
if ($input->has('type')) {
$cacheKey = $input->get('type');
if ($input->has('mediaType')) {
$cacheKey .= ".".$input->get('mediaType');
if ($input->has('tmdbId')) {
$cacheKey .= ".".$input->get('tmdbId');
if ($input->has('imdbId')) {
$cacheKey .= ".".$input->get('imdbId');
if ($input->has('season')) {
$cacheKey .= ".".$input->get('season');
if ($input->has('episode')) {
$cacheKey .= ".".$input->get('episode');
}
}
}
}
}
}
if ($cacheKey !== null) {
$this->torrentioCache->invalidateTags([$cacheKey]);
}
if ($input->has('tags')) {
$this->torrentioCache->invalidateTags($input->get('tags'));
}
return new DeleteCacheResult($input, $cacheKey, $command->tags);
}
}

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

@@ -1,49 +0,0 @@
<?php
namespace App\Torrentio\Action\Input;
use App\Torrentio\Action\Command\DeleteCacheCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements DeleteCacheInput<DeleteCacheCommand>
*/
class DeleteCacheInput implements InputInterface
{
public function __construct(
#[SourceRequest('type', nullify: true)]
public ?string $type,
#[SourceRequest('mediaType', nullify: true)]
public ?string $mediaType,
#[SourceRequest('tmdbId', nullify: true)]
public ?string $tmdbId,
#[SourceRequest('imdbId', nullify: true)]
public ?string $imdbId,
#[SourceRequest('season', nullify: true)]
public ?int $season,
#[SourceRequest('episode', nullify: true)]
public ?int $episode,
#[SourceRequest('tags', nullify: true)]
public ?array $tags,
) {}
public function toCommand(): CommandInterface
{
return new DeleteCacheCommand(
type: $this->type,
mediaType: $this->mediaType,
tmdbId: $this->tmdbId,
imdbId: $this->imdbId,
season: $this->season,
episode: $this->episode
);
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Torrentio\Action\Result;
use Aimeos\Map;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteCacheResult implements ResultInterface
{
public function __construct(
public Map $result,
public ?string $cacheKey = null,
public ?array $tags = null,
) {}
}

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,54 +6,34 @@ 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 $torrentioCache, 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, array $filter = []): array public function search(string $imdbCode, string $type, array $filter = []): array
{ {
$cacheKey = "torrentio.{$imdbCode}"; $cacheKey = "torrentio.{$imdbCode}";
$results = $this->torrentioCache->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 [];
}); });
return $this->parse($results, $filter); return $this->parse($results, $filter);
@@ -62,30 +42,15 @@ class Torrentio
public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array
{ {
$cacheKey = "torrentio.$imdbId.$season.$episode"; $cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->torrentioCache->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) {
throw new TorrentioRateLimitException();
}
return $this->parse($results, []); return $this->parse($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,21 +0,0 @@
<?php
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\DeleteCacheHandler;
use App\Torrentio\Action\Input\DeleteCacheInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
#[Route('/api/torrentio/cache', name: 'api.torrentio.cache', methods: ['POST'])]
public function deleteCache(
DeleteCacheInput $deleteCacheInput,
DeleteCacheHandler $deleteCacheHandler,
): Response {
$result = $deleteCacheHandler->handle($deleteCacheInput->toCommand());
return $this->json($result, Response::HTTP_OK);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -74,18 +74,6 @@
"phpstan.dist.neon" "phpstan.dist.neon"
] ]
}, },
"stof/doctrine-extensions-bundle": {
"version": "1.14",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.2",
"ref": "e805aba9eff5372e2d149a9ff56566769e22819d"
},
"files": [
"config/packages/stof_doctrine_extensions.yaml"
]
},
"symfony/asset-mapper": { "symfony/asset-mapper": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {

View File

@@ -11,31 +11,9 @@ module.exports = {
"bg-green-400", "bg-green-400",
"bg-purple-400", "bg-purple-400",
"bg-orange-400", "bg-orange-400",
"bg-blue-600",
"bg-rose-600",
"alert-success",
"alert-warning",
"min-w-64",
"rotate-180",
"-rotate-180",
"transition-opacity",
"ease-in",
"duration-700",
"opacity-100"
], ],
theme: { theme: {
extend: { extend: {},
animation: {
fade: 'fadeIn .3s ease-in-out',
},
keyframes: {
fadeIn: {
from: { opacity: 0 },
to: { opacity: 1 },
},
},
},
}, },
plugins: [], plugins: [],
} }

View File

@@ -19,12 +19,7 @@
</div> </div>
<div class="col-span-5 h-screen overflow-y-scroll"> <div class="col-span-5 h-screen overflow-y-scroll">
<twig:Header /> <twig:Header />
<div class="px-4 mt-3 flex flex-row justify-between"> <h2 class="px-4 my-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<h2 class="m-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex flex-row gap-1 align-end justify-end items-end">
{% block action_buttons %}{% endblock %}
</div>
</div>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,9 @@
<div{{ attributes.defaults(stimulus_controller('download_list')) }} class="min-w-48" > <div{{ attributes.defaults(stimulus_controller('download_list', {isWidget: this.isWidget, perPage: this.perPage})) }} class="min-w-48" >
{% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %} {% set table_body_id = (type == "complete") ? "complete_downloads" : "active_downloads" %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %}
{% if this.isWidget == false %} {% set show_view_all = true %}
<div class="flex flex-row mb-2 justify-end"> {% else %}
<twig:DownloadSearch search_path="app_search" placeholder="Find {{ type == "complete" ? "a" : "an" }} {{ type }} download..." /> {% set show_view_all = false %}
</div>
{% endif %} {% endif %}
<table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}> <table id="downloads" class="divide-y divide-gray-200 bg-gray-50 overflow-hidden rounded-lg table-auto w-full" {{ turbo_stream_listen('App\\Download\\Framework\\Entity\\Download') }}>
@@ -38,15 +37,13 @@
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50"> <tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.downloads.items|length > 0 %} {% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %} {% for download in this.downloads.items %}
<twig:DownloadListRow download="{{ download }}" isWidget="{{ this.isWidget }}" /> <twig:DownloadListRow download="{{ download }}" isWidget="{{ this.isWidget }}" perPage="{{ this.perPage }}" />
{% endfor %} {% endfor %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %} <tr id="download_view_all" class="{{ show_view_all == false ?? "hidden" }}" {{ stimulus_target('download_list', 'viewAllBtn')}} >
<tr id="download_view_all"> <td class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black truncate" colspan="100%">
<td class="py-2 whitespace-nowrap bg-orange-300 uppercase text-xs font-medium text-center text-black truncate" colspan="100%"> <a href="{{ path('app_downloads') }}">View All Downloads</a>
<a href="{{ path('app_downloads') }}">View All Downloads</a> </td>
</td> </tr>
</tr>
{% endif %}
{% else %} {% else %}
<tr id="{{ table_body_id }}_no_downloads"> <tr id="{{ table_body_id }}_no_downloads">
<td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium text-gray-800 dark:text-stone-800" colspan="100%"> <td class="px-6 py-4 whitespace-nowrap text-xs uppercase text-center font-medium text-gray-800 dark:text-stone-800" colspan="100%">

View File

@@ -1,14 +1,6 @@
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}"> <tr{{ attributes }} id="ad_download_{{ download.id }}" {{ stimulus_target('download_list', 'downloadRow') }} isBroadcasted="{{ isBroadcasted ?? 'false' }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}" {{ download.title }}
class="mr-1 hover:underline rounded-md"
>
{{ download.title }}
</a>
{% if download.mediaType == "tvshows" %}
&mdash; <span class="ml-1">(S{{ download.ptn.season }}E{{ download.ptn.episode }})</span>
{% endif %}
</td> </td>
{% if isWidget == false %} {% if isWidget == false %}
@@ -32,20 +24,13 @@
<twig:StatusBadge color="green" status="Complete" /> <twig:StatusBadge color="green" status="Complete" />
{% endif %} {% endif %}
</td> </td>
<td id="action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center"> <td class="px-6 py-4 flex flex-row align-middle justify-center">
{% if download.status == 'In Progress' and download.progress < 100 %} <button {{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}>
<button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}> <twig:ux:icon
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" /> name="ic:twotone-cancel" width="18px"
</button> class="rounded-full align-middle text-red-600"
{% elseif download.status == 'Paused' %} title="Remove {{ download.title }} from your Download list. This will not delete the file."
<button id="resume_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'resumeDownload', 'click', {id: download.id}) }}> />
<twig:ux:icon name="icon-park-twotone:play" width="16.75px" height="16.75px" class="rounded-full" /> </button>
</button>
{% endif %}
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %}
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit>
Are you sure you want to delete <span class="font-bold">{{ download.filename }}</span>?
</twig:Modal>
</td> </td>
</tr> </tr>

View File

@@ -1,31 +0,0 @@
<div {{ attributes }} class="w-full max-w-sm min-w-[200px]">
<div class="relative">
<form>
<input
data-model="term"
class="w-full bg-transparent
placeholder:text-slate-200 text-gray-50
text-sm border-b border-orange-500 pl-3 pr-28 py-2 transition
duration-300 ease focus:outline-none focus:border-orange-400 hover:border-orange-300
shadow-sm focus:shadow"
placeholder="{{ placeholder ?? 'TV Show, Movie...' }}"
/>
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" data-loading="removeClass(hidden)" class="absolute top-2 right-16 text-orange-500 hidden text-end" />
<button
class="absolute top-1 right-1 flex items-center
bg-green-600 py-1 px-2 text-center
text-md text-white transition-all
focus:bg-green-700 active:bg-green-700 hover:bg-green-700
text-white bg-green-600 text-xs
rounded-ms bg-opacity-80
"
onclick="return false;"
>
Search
</button>
</form>
</div>
</div>

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