Compare commits

..

82 Commits

Author SHA1 Message Date
Brock H Caldwell
22b2b46da5 fix: disable multiplatform builds 2025-11-04 13:23:35 -06:00
Brock H Caldwell
7cc48ffc73 chore: docker cleanup/refactoring 2025-11-04 12:55:19 -06:00
Brock H Caldwell
adb79db8f5 feat: allows configuring the worker service and processes via command options and env vars 2025-11-04 12:19:37 -06:00
Brock H Caldwell
9ca87af938 wip: generates supervisord config at runtime 2025-11-04 09:54:41 -06:00
Brock H Caldwell
b01840b111 fix: adds process for async transport to worker 2025-11-03 16:35:26 -06:00
Brock H Caldwell
1759b6dfdc fix: send monitors to new transport 2025-11-03 14:45:29 -06:00
Brock H Caldwell
2d3bc35e45 fix: incorrect transport for scheduler 2025-11-02 23:04:51 -06:00
Brock H Caldwell
69f07b57ce fix(worker): removes unnecessary arguments passed to container 2025-11-02 22:51:13 -06:00
Brock H Caldwell
b86028acee wip: uses supervisord for worker and scheduler 2025-11-02 21:29:48 -06:00
Brock H Caldwell
b67781fe23 fix: adds created/updated at to event log 2025-11-02 16:16:19 -06:00
Brock H Caldwell
6920b82684 fix(migration): tries to set default value of null for created_at 2025-11-02 13:17:48 -06:00
Brock H Caldwell
0f291aa147 fix(migrations): migrations complains about sessions table not existing 2025-11-02 09:58:57 -06:00
Brock H Caldwell
c4160081a1 feat: custom 500 page 2025-11-02 00:13:59 -05:00
Brock H Caldwell
5d414590cb feat: logs monitor events 2025-11-02 00:07:31 -05:00
Brock H Caldwell
d28b743684 feat: logs download events 2025-11-01 23:58:15 -05:00
Brock H Caldwell
c4e8e9b35e feat: custom 404 page 2025-11-01 23:50:04 -05:00
Brock H Caldwell
6fbd56c952 task: adds event log module 2025-11-01 15:27:21 -05:00
Brock H Caldwell
f5732fbcea task: adds psalm.xml 2025-11-01 14:06:07 -05:00
Brock H Caldwell
0f095ab7f8 fix: bad parsing of torrentio episode results 2025-10-25 12:59:15 -05:00
Brock H Caldwell
2e376337fa fix: rekeys season list 2025-10-19 21:37:49 -05:00
Brock H Caldwell
fc203f1bd3 fix: removes dir 2025-10-19 18:41:49 -05:00
Brock Caldwell
2eda8e0808 fix: uses native logging 2025-10-19 18:37:51 -05:00
d3431b76e2 feat: transfers half a cent off each transaction into Dan's personal account 2025-10-10 15:16:49 -05:00
a978469564 fix: removes test folder 2025-10-10 15:13:21 -05:00
3097189c49 feat: new extension to transfer half a cent off each transaction into my personal account 2025-10-10 15:09:10 -05:00
6f11de70e0 fix: reverts to default docker logging 2025-10-04 21:23:44 -05:00
4e06fe6636 fix(MonitorTvEpisodeHandler): fixes check against null variable 2025-09-19 22:18:58 -05:00
fd46abf58f chore(MonitorTvShowHandler): only evaluates episides starting in the current season and forward 2025-09-19 22:16:28 -05:00
2237a45d6f fix: passes latest season to add monitor call 2025-09-19 17:30:57 -05:00
846de2c257 fix: faulty if statement 2025-09-18 21:38:33 -05:00
d01b725435 fix: null check 2025-09-18 21:37:51 -05:00
7562597629 fix(monitor): adds null checkk and handles accordingly 2025-09-18 21:02:47 -05:00
deb0333635 fix: tmdb episode details failing from missing imdb id 2025-09-18 18:58:43 -05:00
c8e190f9e8 fix: removes deep link to episode for monitors since episode doesnt always exist and parsing can be iffy 2025-09-17 13:57:45 -05:00
538fde40fe fix: actually fixes broken monitor -> media links 2025-09-17 13:10:07 -05:00
da7a267e2a fix(GetMediaInfoHandler): missing handler for TvEpisode type 2025-09-17 11:52:41 -05:00
daf9b2c18b fix: styles tweaks 2025-09-17 11:47:31 -05:00
d9e5e62f5d chore: style tweak 2025-09-17 11:40:37 -05:00
d4fc7693e3 fix: updates current season when switching seasons 2025-09-17 11:06:04 -05:00
1263ad20a6 fix: reverts back to using dedicated dev dockerfile 2025-09-17 10:15:55 -05:00
e8764bb13b fix: reverts back to using dedicated dev dockerfile 2025-09-17 10:08:22 -05:00
a267bab86e chore: cleanup 2025-09-16 22:13:39 -05:00
9653189bff feat: lazy loads torrentio results on episodes, adds loading indicator for episodes and movies 2025-09-16 21:48:23 -05:00
36836c4d36 fix: search results not rendering automatically 2025-09-16 17:03:40 -05:00
61e4b25212 feat: makes events clickable 2025-09-16 13:17:29 -05:00
209266597e dev: uses base images in local dev 2025-09-16 12:19:40 -05:00
ca89eff236 fix: adds .dockerignore, includes .git in deployment.properties 2025-09-15 14:55:32 -05:00
53da7a746b fix: adds app version back to worker & scheduler images 2025-09-15 14:26:15 -05:00
981699bc13 chore: creates base worker image 2025-09-15 13:15:10 -05:00
52f460ff62 fix: missing imdb id on torrentio results 2025-09-15 12:34:04 -05:00
a42e0d4d1a fix: handles internal app version better 2025-09-15 12:33:16 -05:00
3e4a2d9bb1 fix: references correct base image tag 2025-09-15 10:06:35 -05:00
af8a30826c fix: monitors after tmdb updates 2025-09-15 09:41:20 -05:00
09e1c75826 fix: replaces test swarm file with dynamic node port 2025-09-14 23:00:40 -05:00
f1b8b34359 fix: adds deploy.test.compose.yml 2025-09-14 22:53:06 -05:00
6f9db68664 fix: installs wget into worker image 2025-09-14 22:11:58 -05:00
aeb706b5af build: creates base image to speed up build times 2025-09-14 22:10:04 -05:00
7918c260e5 fix: uses base docker image 2025-09-09 16:30:22 -05:00
38130ea0ec fix: typo 2025-09-09 12:11:40 -05:00
da403958dc fix: docker reference 2025-09-09 12:04:14 -05:00
c2bafabb20 fix: builds worker & scheduler FROM app 2025-09-09 11:48:43 -05:00
e6983aedf9 Merge branch 'dev-tmdb-cleanup' 2025-09-09 11:14:02 -05:00
e9edd6a35a fix: incorrect air date on movies, severance returning 500 from 0 episodes in new season 2025-09-09 11:13:42 -05:00
ee076518b3 fix: style 2025-09-08 21:28:01 -05:00
a2f16398be fix: composer.lock 2025-09-08 20:22:31 -05:00
0f03199eb4 fix: cascade removes monitors 2025-09-08 16:05:31 -05:00
d63d477ed1 chore: cleanup 2025-09-08 15:59:20 -05:00
458229c7ed feat: displays media genres 2025-09-08 14:36:41 -05:00
6748188256 fix: removes dd() 2025-09-08 14:21:50 -05:00
b42924048f chore: makes better use of symfony denormalizer 2025-09-08 14:20:33 -05:00
c0f1473037 wip: mostly working tmdb client 2025-09-05 15:43:01 -05:00
fc797a3a0f chore: tmdb client cleanup 2025-09-02 16:37:26 -05:00
b8b71fa5b3 fix: uses symfony de-normalizer to map tmdb data to objects 2025-09-01 21:01:10 -05:00
662e2600f6 fix: torrentio client 2025-08-31 19:33:37 -05:00
aa042e8275 fix: creates dedicated http client for torrentio 2025-08-31 18:01:40 -05:00
57498b1abf fix: increases column size 2025-08-31 13:41:07 -05:00
fed1e1e122 fix: adds favicon 2025-08-31 13:40:53 -05:00
9eef567974 feat: simple related media block on results page 2025-08-29 16:29:20 -05:00
070723581a fix: view all monitors button color 2025-08-29 15:13:30 -05:00
f3a5c2012e fix: calendar icon on mobile 2025-08-29 01:08:47 -05:00
5581a82554 fix: ical subscription not loading 2025-08-28 20:19:31 -05:00
3703272f59 fix: makes ical url publicly accessible if user has option enabled 2025-08-27 22:58:24 -05:00
115 changed files with 2452 additions and 997 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.idea
.phpunit.cache
.php-cs-fixer.cache
.env.test
.gitignore
bolt.db
bash
build.xml
deploy.compose.yml
phpstan.dist.neon
phpunit.dist.xml
nomad.deploy.hcl
deployment.properties
var/download/*

View File

@@ -1,19 +1,10 @@
FROM dunglas/frankenphp:php8.4 FROM code.caldwell.digital/home/torsearch-base:php8.4
ENV SERVER_NAME=":80" ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime" ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1" ENV APP_VERSION="0.0.0-dev"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apt update && apt install -y wget
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ] HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ]
COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -13,34 +13,7 @@ export default class extends Controller {
tmdbId: String, tmdbId: String,
imdbId: String, imdbId: String,
title: String, title: String,
} season: Number,
initialize() {
// Called once when the controller is first instantiated (per element)
// Here you can initialize variables, create scoped callables for event
// listeners, instantiate external libraries, etc.
// this._fooBar = this.fooBar.bind(this)
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
} }
toggle() { toggle() {
@@ -53,34 +26,13 @@ export default class extends Controller {
imdbId: this.imdbIdValue, imdbId: this.imdbIdValue,
title: this.titleValue, title: this.titleValue,
monitorType: 'tvshows', monitorType: 'tvshows',
season: this.seasonValue
}); });
if (this.hasDialogOutlet) { if (this.hasDialogOutlet) {
this.dialogOutlet.close(); this.dialogOutlet.close();
} }
} }
async monitorSeason() {
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvseason',
season: this.resultFilterOutlet.activeFilter['season'],
});
}
async monitorEpisode() {
// ToDo: figure out how to set episode
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvepisode',
season: this.resultFilterOutlet.activeFilter['season'],
episode: '',
});
}
async makeMonitor(body) { async makeMonitor(body) {
const response = await fetch('/api/monitor', { const response = await fetch('/api/monitor', {
method: 'POST', method: 'POST',
@@ -90,7 +42,6 @@ export default class extends Controller {
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
return await response.json(); return await response.json();
} }
} }

View File

@@ -13,7 +13,6 @@ export default class extends Controller {
}; };
static targets = ['list'] static targets = ['list']
static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false optionsLoaded = false
@@ -28,7 +27,6 @@ export default class extends Controller {
this.options = this.element.querySelectorAll('tbody tr'); this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.resultCountEl.innerText = this.options.length; this.resultCountEl.innerText = this.options.length;
this.loadingIconOutlet.toggleIcon();
document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}})); document.dispatchEvent(new CustomEvent('optionsLoaded', {detail: {options: this.options}}));
} }
} }

View File

@@ -22,7 +22,7 @@ export default class extends Controller {
defaultOptions = '<option value="-">-</option>'; defaultOptions = '<option value="-">-</option>';
static outlets = ['tv-episode-list'] static outlets = ['tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'loadingIcon', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected', 'currentSeason']
static values = { static values = {
'imdbId': String, 'imdbId': String,
'media-type': String, 'media-type': String,
@@ -32,7 +32,6 @@ export default class extends Controller {
async connect() { async connect() {
await this.setInitialFilter(); await this.setInitialFilter();
this.setTimerToStopLoadingIcon();
this.element.filterResults = this.filter.bind(this); this.element.filterResults = this.filter.bind(this);
document.addEventListener('optionsLoaded', this.loadOptions.bind(this)); document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
} }
@@ -48,10 +47,6 @@ export default class extends Controller {
} }
} }
setTimerToStopLoadingIcon() {
setTimeout(() => this.loadingIconTarget.hideIcon(), 10000);
}
// Event is fired from movies/tvshows controllers to populate this data // Event is fired from movies/tvshows controllers to populate this data
async loadOptions({detail: { options }}) { async loadOptions({detail: { options }}) {
await options.forEach((option) => { await options.forEach((option) => {
@@ -99,7 +94,9 @@ export default class extends Controller {
} }
setSeason(event) { setSeason(event) {
console.log('hurrrr');
this.tvEpisodeListOutlet.setSeason(event.target.value); this.tvEpisodeListOutlet.setSeason(event.target.value);
this.currentSeasonTarget.innerText = event.target.value;
} }
downloadSeason() { downloadSeason() {

View File

@@ -38,21 +38,21 @@ export default class extends Controller {
return ` return `
<span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon"> <span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon">
<svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg> <svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg>
</span> </span>`;
`;
} }
event.detail.options.render.option = (data, escape) => { event.detail.options.render.option = (data, escape) => {
if (data.data.description.length > 60) { console.log(data);
data.data.description = data.data.description.substring(0, 107) + "..."; if (data.data.overview.length > 60) {
data.data.overview = data.data.overview.substring(0, 107) + "...";
} }
return `<div class="flex flex-row"> return `<div class="flex flex-row">
<img src="${data.data.poster}" class="w-16 rounded-md"> <img src="${data.data.poster}" class="w-16 rounded-md">
<div class="p-2 flex flex-col"> <div class="p-2 flex flex-col">
<h2>${data.data.title}</h2> <h2>${data.data.title}</h2>
<p class="max-w-[60ch] text-wrap">${data.data.description}</p> <p class="max-w-[60ch] text-wrap">${data.data.overview}</p>
</div> </div>
</div> </div>`;
`
} }
} }

View File

@@ -19,7 +19,6 @@ export default class extends Controller {
}; };
static targets = ['list', 'count', 'episodeSelector',] static targets = ['list', 'count', 'episodeSelector',]
static outlets = ['loading-icon']
options = [] options = []
@@ -35,6 +34,5 @@ export default class extends Controller {
this.countTarget.innerText = 0; this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true; this.episodeSelectorTarget.disabled = true;
} }
this.loadingIconOutlet.increaseCount();
} }
} }

View File

@@ -199,6 +199,10 @@ dialog[data-dialog-target="dialog"][closing] {
@apply flex flex-col gap-1 justify-between; @apply flex flex-col gap-1 justify-between;
} }
/** FullCalendar **/
#upcoming_episodes_calendar .fc-event-main .fc-event-title-container {
cursor: pointer !important;
}
.fc-col-header-cell { .fc-col-header-cell {
@apply bg-orange-500/60 text-white; @apply bg-orange-500/60 text-white;
} }

22
bash/build_base.sh Executable file
View File

@@ -0,0 +1,22 @@
# torsearch-app is built from this base
export APP_FRANKENPHP_TAG=php8.4
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
docker build -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
docker push code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base:latest
# torsearch-worker & torsearch-scheduler are built from this base
export WORKER_FRANKENPHP_TAG=php8.4-alpine
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
docker build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
docker push code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG}
docker push code.caldwell.digital/home/torsearch-base-worker:latest
# torsearch-worker-supervisord
export ALPINE_VERSION=3.22
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
docker build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
docker push code.caldwell.digital/home/torsearch-base-worker-supervisord:latest

View File

@@ -33,7 +33,9 @@ services:
worker: worker:
build: . build:
dockerfile: docker/Dockerfile.worker
context: .
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
@@ -41,18 +43,19 @@ services:
tty: true tty: true
environment: environment:
TZ: America/Chicago TZ: America/Chicago
command: php /app/bin/console messenger:consume async -vv --time-limit=3600
scheduler: scheduler:
build: . build:
dockerfile: docker/Dockerfile.scheduler
context: .
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- $PWD:/app - $PWD:/app
- $PWD/var/download:/var/download
tty: true
environment: environment:
TZ: America/Chicago TZ: America/Chicago
command: php /app/bin/console messenger:consume scheduler_monitor -vv WORKER_MONITOR: 2
tty: true
redis: redis:
@@ -88,6 +91,8 @@ services:
image: adminer image: adminer
ports: ports:
- "8081:8080" - "8081:8080"
environment:
ADMINER_DEFAULT_SERVER: database
volumes: volumes:

View File

@@ -25,6 +25,8 @@
"p3k/emoji-detector": "^1.2", "p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0", "php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1", "php-tmdb/api": "^4.1",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"predis/predis": "^2.4", "predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"spatie/icalendar-generator": "^3.0", "spatie/icalendar-generator": "^3.0",
@@ -46,9 +48,13 @@
"symfony/messenger": "7.3.*", "symfony/messenger": "7.3.*",
"symfony/notifier": "7.3.*", "symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*", "symfony/ntfy-notifier": "7.3.*",
"symfony/object-mapper": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*", "symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*", "symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27", "symfony/ux-autocomplete": "^2.27",

79
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9ffd10f98137e8975de2c04ac2412ed5", "content-hash": "c133ccd27ac6a41256bdc69129c16546",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -7783,6 +7783,79 @@
], ],
"time": "2025-02-13T10:27:54+00:00" "time": "2025-02-13T10:27:54+00:00"
}, },
{
"name": "symfony/object-mapper",
"version": "v7.3.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/object-mapper.git",
"reference": "f7f9833d9fcc8361239c1dae5495aa9e43ece0b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/object-mapper/zipball/f7f9833d9fcc8361239c1dae5495aa9e43ece0b5",
"reference": "f7f9833d9fcc8361239c1dae5495aa9e43ece0b5",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/container": "^2.0"
},
"conflict": {
"symfony/property-access": "<7.2"
},
"require-dev": {
"symfony/property-access": "^7.2",
"symfony/var-exporter": "^7.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\ObjectMapper\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a way to map an object to another object",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/object-mapper/tree/v7.3.3"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-08-13T14:03:15+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v7.3.0", "version": "v7.3.0",
@@ -13325,7 +13398,7 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "stable", "minimum-stability": "stable",
"stability-flags": {}, "stability-flags": [],
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
@@ -13333,7 +13406,7 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*" "ext-iconv": "*"
}, },
"platform-dev": {}, "platform-dev": [],
"platform-overrides": { "platform-overrides": {
"php": "8.4" "php": "8.4"
}, },

View File

@@ -41,7 +41,13 @@ doctrine:
is_bundle: false is_bundle: false
dir: '%kernel.project_dir%/src/Monitor/Framework/Entity' dir: '%kernel.project_dir%/src/Monitor/Framework/Entity'
prefix: 'App\Monitor\Framework\Entity' prefix: 'App\Monitor\Framework\Entity'
alias: Download alias: Monitor
EventLog:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/EventLog/Framework/Entity'
prefix: 'App\EventLog\Framework\Entity'
alias: EventLog
controller_resolver: controller_resolver:
auto_mapping: false auto_mapping: false

View File

@@ -15,6 +15,26 @@ framework:
max_retries: 1 max_retries: 1
multiplier: 1 multiplier: 1
download:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: download
retry_strategy:
max_retries: 3
multiplier: 1
monitor:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
queue_name: monitor
retry_strategy:
max_retries: 1
multiplier: 1
media_cache: media_cache:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%' dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options: options:
@@ -36,12 +56,12 @@ framework:
routing: routing:
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async 'App\Download\Action\Command\DownloadMediaCommand': download
'App\Download\Action\Command\DownloadSeasonCommand': async 'App\Download\Action\Command\DownloadSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async 'App\Monitor\Action\Command\MonitorTvEpisodeCommand': monitor
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async 'App\Monitor\Action\Command\MonitorTvSeasonCommand': monitor
'App\Monitor\Action\Command\MonitorTvShowCommand': async 'App\Monitor\Action\Command\MonitorTvShowCommand': monitor
'App\Monitor\Action\Command\MonitorMovieCommand': async 'App\Monitor\Action\Command\MonitorMovieCommand': monitor
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache 'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache
# when@test: # when@test:

View File

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

View File

@@ -6,6 +6,14 @@ controllersBase:
defaults: defaults:
schemes: [ 'https' ] schemes: [ 'https' ]
controllersEventLog:
resource:
path: ../src/EventLog/Framework/Controller/
namespace: App\EventLog\Framework\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersLibrary: controllersLibrary:
resource: resource:
path: ../src/Library/Framework/Controller/ path: ../src/Library/Framework/Controller/

View File

@@ -32,7 +32,7 @@ parameters:
app.cache.redis.host.default: 'redis://redis' app.cache.redis.host.default: 'redis://redis'
# Various configs # Various configs
app.default.version: '0.dev' app.default.version: '0.0.0-dev'
app.default.timezone: 'America/Chicago' app.default.timezone: 'America/Chicago'
# Auth # Auth

View File

@@ -2,7 +2,7 @@ services:
app: app:
image: registry.caldwell.digital/home/torsearch-app:${TAG} image: registry.caldwell.digital/home/torsearch-app:${TAG}
ports: ports:
- '8001:80' - "${SWARM_PORT}:80"
environment: environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
@@ -13,12 +13,6 @@ services:
- /mnt/media/downloads/tvshows:/var/download/tvshows - /mnt/media/downloads/tvshows:/var/download/tvshows
- mercure_data:/data - mercure_data:/data
- mercure_config:/config - mercure_config:/config
depends_on:
- database
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12202"
worker: worker:
@@ -26,16 +20,10 @@ 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
restart: always
command: -vv --time-limit=3600 --limit=10
deploy: deploy:
replicas: 2 replicas: 2
depends_on: depends_on:
- app - app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12203"
scheduler: scheduler:
@@ -43,28 +31,10 @@ 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
restart: always
command: -vv
depends_on: depends_on:
- app - app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12204"
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
volumes: volumes:
mysql:
mercure_config: mercure_config:
mercure_data: mercure_data:
redis_data:

View File

@@ -10,6 +10,7 @@ templates
var var
vendor vendor
build.xml build.xml
.git
.env .env
.env.local .env.local
composer.json composer.json

View File

@@ -1,19 +1,8 @@
FROM dunglas/frankenphp FROM code.caldwell.digital/home/torsearch-base:php8.4
ENV SERVER_NAME=":80" ARG APP_VERSION="0.0.0-dev"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}" ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
COPY . /app COPY . /app
COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint COPY --chmod=775 docker/app/entrypoint.sh /usr/local/bin/docker-entrypoint
COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile

View File

@@ -0,0 +1,14 @@
ARG FRANKENPHP_TAG
FROM dunglas/frankenphp:${FRANKENPHP_TAG}
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache

View File

@@ -0,0 +1,35 @@
#####
# This version of Torsearch runs the scheduler, downloader, and worker
# in a single container. Each process is managerd by supervisord
# and can be configured via environment variables and more
# than one of these containers cans till be run.
#####
ARG ALPINE_VERSION="3.22"
FROM alpine:${ALPINE_VERSION} AS build_stage
RUN apk add --no-cache \
curl \
php84 \
php84-curl \
php84-fileinfo \
php84-gd \
php84-mbstring \
php84-mysqli \
php84-opcache \
php84-openssl \
php84-pdo_mysql \
supervisor
RUN ln -s /usr/bin/php84 /usr/bin/php
RUN mkdir -p /etc/supervisor/conf.d
# We start supervisord and supervisord starts
# respective service in the configuration file.
ENTRYPOINT ["/app/bin/console", "init:worker"]
# Message transports can be enabled by passing them as command options.
# Inlcluding a number with the option will start that amount of processes
# for the transport. Not supplying a number will default to 1.
# --download --monitor OR --download 2 --monitor

View File

@@ -1,21 +1,21 @@
FROM dunglas/frankenphp:php8.4-alpine ###
# This version of Torsearch can run the scheduler, downloader, and
# worker in one container. Each process is managerd by supervisord
# and can be configured via environment variables, and more than
# one of these containers can still be run.
###
ENV SERVER_NAME=":80" # Default to latest, but should pass in a version
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ARG APP_VERSION="latest"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev" # Start with our base worker image
ENV APP_VERSION="${APP_VERSION}" FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
RUN install-php-extensions \ # Set the APP_VERSION in the image
pdo_mysql \ ENV APP_VERSION=${APP_VERSION}
gd \
intl \
zip \
opcache
COPY . /app # Copy the actual application code from the previously built app
COPY --chown=1000:1000 ./ /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ] # To retain backwards compatibility, default to async & download transports
CMD [ "--async", "--download" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

View File

@@ -1,23 +1,21 @@
FROM dunglas/frankenphp:php8.4-alpine ###
# This version of Torsearch can run the scheduler, downloader, and
# worker in one container. Each process is managerd by supervisord
# and can be configured via environment variables, and more than
# one of these containers can still be run.
###
ENV SERVER_NAME=":80" # Default to latest, but should pass in a version
ENV CADDY_GLOBAL_OPTIONS="auto_https off" ARG APP_VERSION="latest"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev" # Start with our base worker image
ENV APP_VERSION="${APP_VERSION}" FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
RUN install-php-extensions \ # Set the APP_VERSION in the image
pdo_mysql \ ENV APP_VERSION=${APP_VERSION}
gd \
intl \
zip \
opcache
RUN apk add --no-cache wget # Copy the actual application code from the previously built app
COPY --chown=1000:1000 ./ /app
COPY . /app # To retain backwards compatibility, default to async & download transports
CMD [ "--async", "--download" ]
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0

10
docker/worker/async.conf Normal file
View File

@@ -0,0 +1,10 @@
[program:torsearch-worker]
command=/app/bin/console messenger:consume async -vv --time-limit 3600
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
startretries=3
process_name=%(program_name)s_%(process_num)02d

View File

@@ -0,0 +1,10 @@
[program:torsearch-downloader]
command=/app/bin/console messenger:consume download -vv --time-limit 3600
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
startretries=3
process_name=%(program_name)s_%(process_num)02d

View File

@@ -0,0 +1,10 @@
[program:torsearch-scheduler]
command=/app/bin/console messenger:consume scheduler_monitor monitor -vv --time-limit 21600
numprocs=1
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autorestart=true
startretries=3
process_name=%(program_name)s_%(process_num)02d

View File

@@ -0,0 +1,5 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid

View File

@@ -0,0 +1,41 @@
<?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 Version20250831013403 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE IF EXISTS sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
SQL);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251101194723 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 user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
SQL);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251101211617 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'
CREATE TABLE event_log (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) DEFAULT NULL, message LONGTEXT DEFAULT NULL, context JSON DEFAULT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE event_log
SQL);
}
}

View File

@@ -0,0 +1,47 @@
<?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 Version20251102004627 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 event_log ADD user_id INT DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE event_log ADD CONSTRAINT FK_9EF0AD16A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_9EF0AD16A76ED395 ON event_log (user_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 event_log DROP FOREIGN KEY FK_9EF0AD16A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_9EF0AD16A76ED395 ON event_log
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE event_log DROP user_id
SQL);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251102221034 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 event_log 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 event_log DROP created_at, DROP updated_at
SQL);
}
}

17
psalm.xml Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0"?>
<psalm
errorLevel="7"
resolveFromConfigFile="true"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
findUnusedBaselineEntry="true"
findUnusedCode="true"
>
<projectFiles>
<directory name="src" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
</psalm>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -2,16 +2,25 @@
namespace App\Base; namespace App\Base;
use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class ConfigResolver final class ConfigResolver
{ {
const SEMVER_REGEX = '/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/';
private array $messages = []; private array $messages = [];
public function __construct( public function __construct(
private readonly DenormalizerInterface $denormalizer,
#[Autowire(param: 'app.url')] #[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null, private readonly ?string $appUrl = null,
#[Autowire(param: 'app.version')]
private readonly ?string $appVersion = null,
#[Autowire(param: 'app.debrid.real_debrid.key')] #[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null, private readonly ?string $realDebridApiKey = null,
@@ -92,6 +101,13 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin; return $this->authOidcBypassFormLogin;
} }
public function getAppVersion(): AppVersionDto
{
$matches = [];
preg_match(self::SEMVER_REGEX, $this->appVersion, $matches);
return $this->denormalizer->denormalize($matches, AppVersionDto::class);
}
public function getAuthConfig(): array public function getAuthConfig(): array
{ {
return [ return [

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Base\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class AppVersionDto
{
#[SerializedPath('[1]')]
public string|int $major = 0;
#[SerializedPath('[2]')]
public string|int $minor = 0;
#[SerializedPath('[3]')]
public string|int $patch = 0;
#[SerializedPath('[4]')]
public ?string $pre = null;
#[SerializedPath('[5]')]
public ?string $build = null;
public function __toString()
{
return 'v' . $this->major . '.' . $this->minor . '.' . $this->patch . ($this->pre ? '-' . $this->pre : '') . ($this->build ? '+' . $this->build : '');
}
}

View File

@@ -6,4 +6,5 @@ enum MediaType: string
{ {
case Movie = 'movies'; case Movie = 'movies';
case TvShow = 'tvshows'; case TvShow = 'tvshows';
case TvEpisode = 'tvepisode';
} }

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Base\Framework\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Twig\Environment;
class InitWorker extends Command
{
public function __construct(
private Environment $twig,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->setName('init:worker')
->addOption('async', null, InputOption::VALUE_OPTIONAL, 'Run the async worker.',false)
->addOption('download', null, InputOption::VALUE_OPTIONAL, 'Run the download worker.', false)
->addOption('monitor', null, InputOption::VALUE_OPTIONAL, 'Run the monitor worker.', false)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$configFile = $this->twig->render("config/supervisord.conf.twig", []);
if ($this->optionExists($input, $output, 'async')) {
$configFile .= $this->twig->render("config/async.conf.twig", [
'replicas' => $this->getOptionValue('async', $input),
]) . "\n\n";
}
if ($this->optionExists($input, $output, 'download')) {
$configFile .= $this->twig->render("config/download.conf.twig", [
'replicas' => $this->getOptionValue('download', $input),
]). "\n\n";
}
if ($this->optionExists($input, $output, 'monitor')) {
$configFile .= $this->twig->render("config/monitor.conf.twig", [
'replicas' => $this->getOptionValue('monitor', $input),
]). "\n\n";
}
if ("" !== $configFile) {
$output->writeln("[init:worker] Writing /etc/supervisor/conf.d/supervisord.conf");
file_put_contents("/etc/supervisor/conf.d/supervisord.conf", $configFile);
$output->writeln("[init:worker] Starting supervisord");
shell_exec("/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf");
return Command::SUCCESS;
}
$output->writeln("[init:worker] No workers selected. Exiting.");
return Command::FAILURE;
}
private function optionExists(InputInterface $input, OutputInterface $output, string $option): bool
{
if ($input->getOption($option) !== false) {
$value = $input->getOption($option) ?? 1;
$output->writeln("[init:worker] transport: $option // $value // input var");;
return true;
}
$optionKey = 'WORKER_' . strtoupper($option);
if (array_key_exists($optionKey, $_SERVER) && $_SERVER[$optionKey] !== null && $_SERVER[$optionKey] !== '') {
$output->writeln("[init:worker] transport: $option // $_SERVER[$optionKey] // env var");
return true;
}
return false;
}
private function getOptionValue(string $option, InputInterface $input): int
{
if ($input->getOption($option) !== false) {
return $input->getOption($option) ?? 1;
}
$optionKey = 'WORKER_' . strtoupper($option);
return $_SERVER[$optionKey];
}
}

View File

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

View File

@@ -4,32 +4,29 @@ namespace App\Base\Framework\Controller;
use App\Monitor\Action\Command\MonitorTvShowCommand; use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\User\Framework\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController final class IndexController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly TmdbClient $tmdb,
private readonly MonitorTvShowHandler $monitorTvShowHandler,
) {} ) {}
#[Route('/', name: 'app_index')] #[Route('/', name: 'app_index')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
/** @var User $user */
$user = $this->getUser();
return $this->render('index/index.html.twig', [ return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(), 'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(), 'recent_downloads' => $this->getUser()->getDownloads(),
'popular_movies' => $this->tmdb->popularMovies(1, 6), 'popular_movies' => $this->tmdb->popularMovies(),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6), 'popular_tvshows' => $this->tmdb->popularTvShows(),
]); ]);
} }
@@ -51,8 +48,9 @@ final class IndexController extends AbstractController
} }
#[Route('/test')] #[Route('/test')]
public function monitorTvShow(): Response public function monitorTvShow(MonitorTvShowHandler $handler): Response
{ {
// $handler->handle(new MonitorTvShowCommand(82));
return $this->render('index/test.html.twig', []); return $this->render('index/test.html.twig', []);
} }
} }

View File

@@ -6,6 +6,6 @@ class ImdbMatcher
{ {
public static function isMatch(string $imdbId): bool public static function isMatch(string $imdbId): bool
{ {
return preg_match('/^tt\d{7}$/', $imdbId); return preg_match('/^tt\d{7,20}$/', $imdbId);
} }
} }

View File

@@ -4,17 +4,22 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand; use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult; use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\DownloadEvents;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Library\Action\Command\DeleteMediaFileCommand; use App\Library\Action\Command\DeleteMediaFileCommand;
use App\Library\Action\Handler\DeleteMediaFileHandler; use App\Library\Action\Handler\DeleteMediaFileHandler;
use App\Monitor\MonitorEvents;
use OneToMany\RichBundle\Contract\CommandInterface; 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 Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */ /** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */
readonly class DeleteDownloadHandler implements HandlerInterface readonly class DeleteDownloadHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MessageBusInterface $bus,
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private DeleteMediaFileHandler $deleteMediaFileHandler, private DeleteMediaFileHandler $deleteMediaFileHandler,
) {} ) {}
@@ -31,6 +36,13 @@ readonly class DeleteDownloadHandler implements HandlerInterface
} }
$this->downloadRepository->delete($command->downloadId); $this->downloadRepository->delete($command->downloadId);
$this->bus->dispatch(new AddEventLogCommand(
$download->getUser(),
DownloadEvents::DOWNLOAD_DELETED->type(),
DownloadEvents::DOWNLOAD_DELETED->message(),
(array) $download
));
return new DeleteDownloadResult( return new DeleteDownloadResult(
status: 200, status: 200,
message: 'Success', message: 'Success',

View File

@@ -4,18 +4,22 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult; use App\Download\Action\Result\DownloadMediaResult;
use App\Download\DownloadEvents;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface; use App\Download\Downloader\DownloaderInterface;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use OneToMany\RichBundle\Contract\CommandInterface; 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 Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */ /** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
readonly class DownloadMediaHandler implements HandlerInterface readonly class DownloadMediaHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MessageBusInterface $bus,
private DownloaderInterface $downloader, private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private UserRepository $userRepository, private UserRepository $userRepository,
@@ -23,9 +27,17 @@ readonly class DownloadMediaHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$user = $this->userRepository->find($command->userId);
$this->bus->dispatch(new AddEventLogCommand(
$user,
DownloadEvents::DOWNLOAD_STARTED->type(),
DownloadEvents::DOWNLOAD_STARTED->message(),
(array) $command
));
if (null === $command->downloadId) { if (null === $command->downloadId) {
$download = $this->downloadRepository->insert( $download = $this->downloadRepository->insert(
$this->userRepository->find($command->userId), $user,
$command->url, $command->url,
$command->title, $command->title,
$command->filename, $command->filename,
@@ -57,6 +69,12 @@ readonly class DownloadMediaHandler implements HandlerInterface
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500); throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
} }
$this->bus->dispatch(new AddEventLogCommand(
$user,
DownloadEvents::DOWNLOAD_FINISHED->type(),
DownloadEvents::DOWNLOAD_FINISHED->message(),
(array) $command
));
return new DownloadMediaResult(200, "Success."); return new DownloadMediaResult(200, "Success.");
} }
} }

View File

@@ -3,13 +3,14 @@
namespace App\Download\Action\Handler; namespace App\Download\Action\Handler;
use Aimeos\Map; use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand; use App\Download\Action\Command\DownloadSeasonCommand;
use App\Download\Action\Result\DownloadMediaResult; use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult; use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator; use App\Download\DownloadOptionEvaluator;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory; use App\User\Dto\UserPreferencesFactory;
@@ -27,7 +28,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
public function __construct( public function __construct(
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private LoggerInterface $logger, private LoggerInterface $logger,
private Tmdb $tmdb, private TmdbClient $tmdb,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator, private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler, private GetTvShowOptionsHandler $getTvShowOptionsHandler,
@@ -36,7 +37,8 @@ readonly class DownloadSeasonHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$series = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType); $series = $this->tmdb->tvshowDetails($command->imdbId);
$this->logger->info('> [DownloadTvSeasonHandler] Executing DownloadTvSeasonHandler for "' . $series->title . '" season ' . $command->season); $this->logger->info('> [DownloadTvSeasonHandler] Executing DownloadTvSeasonHandler for "' . $series->title . '" season ' . $command->season);
$episodesInSeason = Map::from($series->episodes[$command->season]); $episodesInSeason = Map::from($series->episodes[$command->season]);

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Download;
enum DownloadEvents
{
case DOWNLOAD_ADDED;
case DOWNLOAD_STARTED;
case DOWNLOAD_FINISHED;
case DOWNLOAD_DELETED;
case DOWNLOAD_ERROR;
public function type(): string
{
return match ($this) {
self::DOWNLOAD_ADDED => 'download_added',
self::DOWNLOAD_STARTED => 'download_started',
self::DOWNLOAD_FINISHED => 'download_finished',
self::DOWNLOAD_DELETED => 'download_deleted',
self::DOWNLOAD_ERROR => 'download_error',
};
}
public function message(): string
{
return match ($this) {
self::DOWNLOAD_ADDED => 'A new download has been added.',
self::DOWNLOAD_STARTED => 'A download has started.',
self::DOWNLOAD_FINISHED => 'A download has finished.',
self::DOWNLOAD_DELETED => 'A download has been deleted.',
self::DOWNLOAD_ERROR => 'A download has encountered an error.',
};
}
}

View File

@@ -4,9 +4,12 @@ namespace App\Download\Downloader;
use App\Base\Service\Broadcaster; use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Download\DownloadEvents;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\EventLog\Action\Command\AddEventLogCommand;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter; use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Messenger\MessageBusInterface;
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; use Symfony\Contracts\Cache\CacheInterface;
@@ -17,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
* @var RedisAdapter $cache * @var RedisAdapter $cache
*/ */
public function __construct( public function __construct(
private MessageBusInterface $bus,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private CacheInterface $cache, private CacheInterface $cache,
@@ -88,6 +92,12 @@ class ProcessDownloader implements DownloaderInterface
} }
} catch (ProcessFailedException $exception) { } catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed'); $downloadEntity->setStatus('Failed');
$this->bus->dispatch(new AddEventLogCommand(
$downloadEntity->getUser()->getId(),
DownloadEvents::DOWNLOAD_ERROR->type(),
DownloadEvents::DOWNLOAD_ERROR->message() . ': ' . $exception->getMessage(),
(array) $downloadEntity
));
} }
$this->entityManager->flush(); $this->entityManager->flush();

View File

@@ -11,7 +11,9 @@ use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Action\Input\DownloadSeasonInput; use App\Download\Action\Input\DownloadSeasonInput;
use App\Download\Action\Input\PauseDownloadInput; use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput; use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\DownloadEvents;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
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;
@@ -41,6 +43,13 @@ class ApiController extends AbstractController
$input->downloadId = $download->getId(); $input->downloadId = $download->getId();
$input->userId = $this->getUser()->getId(); $input->userId = $this->getUser()->getId();
$this->bus->dispatch(new AddEventLogCommand(
$this->getUser(),
DownloadEvents::DOWNLOAD_ADDED->type(),
DownloadEvents::DOWNLOAD_ADDED->message(),
(array) $download
));
try { try {
$this->bus->dispatch($input->toCommand()); $this->bus->dispatch($input->toCommand());
} catch (\Throwable $exception) { } catch (\Throwable $exception) {

View File

@@ -0,0 +1,17 @@
<?php
namespace App\EventLog\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/** @implements CommandInterface<AddEventLogCommand> */
class AddEventLogCommand implements CommandInterface
{
public function __construct(
public UserInterface $user,
public string $type,
public string $message,
public array $context,
) {}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\EventLog\Action\Handler;
use App\EventLog\Framework\Repository\EventLogRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\EventLog\Action\Result\AddEventLogResult;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/*** @implements HandlerInterface<AddEventLogCommand> */
class AddEventLogHandler implements HandlerInterface
{
public function __construct(
private EventLogRepository $repository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$eventLog = $this->repository->insert(
user: $command->user,
type: $command->type,
message: $command->message,
context: $command->context
);
return new AddEventLogResult($eventLog);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\EventLog\Action\Input;
use App\EventLog\Action\Command\AddEventLogCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<AddEventLogCommand> */
class AddEventLogInput implements InputInterface
{
public function __construct(
#[SourceQuery('type')]
#[SourceRequest('type')]
public string $type,
#[SourceQuery('message')]
#[SourceRequest('message')]
public string $message,
#[SourceQuery('context')]
#[SourceRequest('context')]
public array $context = []
){}
public function toCommand(): CommandInterface
{
return new AddEventLogCommand(
$this->type,
$this->message,
$this->context
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\EventLog\Action\Result;
use App\EventLog\Framework\Entity\EventLog;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<AddEventLogResult> */
class AddEventLogResult implements ResultInterface
{
public function __construct(
public EventLog $eventLog,
) {}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\EventLog\Framework\Controller;
use App\Base\Service\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EventLogController extends AbstractController
{
public function __construct(
private readonly Broadcaster $broadcaster,
) {}
#[Route('/alert', name: 'app_alert')]
public function index(): Response
{
$this->broadcaster->alert(
'Added to queue',
'This is a testy test!'
);
return $this->json([
'Success' => 'Published'
]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\EventLog\Framework\Entity;
use App\EventLog\Framework\Repository\EventLogRepository;
use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
#[ORM\Entity(repositoryClass: EventLogRepository::class)]
class EventLog
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(nullable: true)]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $type = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $message = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $context = null;
#[ORM\ManyToOne(inversedBy: 'eventLogs')]
private ?User $user = null;
public function getId(): ?int
{
return $this->id;
}
public function getType(): ?string
{
return $this->type;
}
public function setType(?string $type): self
{
$this->type = $type;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(?string $message): self
{
$this->message = $message;
return $this;
}
public function getContext(): ?array
{
return $this->context;
}
public function setContext(?array $context): self
{
$this->context = $context;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\EventLog\Framework\Repository;
use App\EventLog\Framework\Entity\EventLog;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EventLog>
*/
class EventLogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EventLog::class);
}
public function insert(
User $user,
string $type,
string $message,
array $context = []
): EventLog {
$eventLog = new EventLog()
->setUser($user)
->setType($type)
->setMessage($message)
->setContext($context);
$this->getEntityManager()->persist($eventLog);
$this->getEntityManager()->flush();
return $eventLog;
}
}

View File

@@ -2,20 +2,24 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\AddMonitorCommand; use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult; use App\Monitor\Action\Result\AddMonitorResult;
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\MonitorEvents;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable; use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface; 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 Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<AddMonitorCommand> */ /** @implements HandlerInterface<AddMonitorCommand> */
readonly class AddMonitorHandler implements HandlerInterface readonly class AddMonitorHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MessageBusInterface $bus,
private MonitorRepository $movieMonitorRepository, private MonitorRepository $movieMonitorRepository,
private UserRepository $userRepository, private UserRepository $userRepository,
) {} ) {}
@@ -35,6 +39,13 @@ readonly class AddMonitorHandler implements HandlerInterface
->setSearchCount(0) ->setSearchCount(0)
->setStatus('New'); ->setStatus('New');
$this->bus->dispatch(new AddEventLogCommand(
$user,
MonitorEvents::MONITOR_ADDED->type(),
MonitorEvents::MONITOR_ADDED->message(),
(array) $monitor
));
$this->movieMonitorRepository->getEntityManager()->persist($monitor); $this->movieMonitorRepository->getEntityManager()->persist($monitor);
$this->movieMonitorRepository->getEntityManager()->flush(); $this->movieMonitorRepository->getEntityManager()->flush();

View File

@@ -2,22 +2,22 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand; use App\Download\DownloadEvents;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\DeleteMonitorCommand; use App\Monitor\Action\Command\DeleteMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Action\Result\DeleteMonitorResult; use App\Monitor\Action\Result\DeleteMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository; use App\Monitor\MonitorEvents;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface; 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 Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<DeleteMonitorCommand, DeleteMonitorResult> */ /** @implements HandlerInterface<DeleteMonitorCommand, DeleteMonitorResult> */
readonly class DeleteMonitorHandler implements HandlerInterface readonly class DeleteMonitorHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MessageBusInterface $bus,
private MonitorRepository $monitorRepository, private MonitorRepository $monitorRepository,
) {} ) {}
@@ -27,6 +27,13 @@ readonly class DeleteMonitorHandler implements HandlerInterface
$this->monitorRepository->getEntityManager()->remove($monitor); $this->monitorRepository->getEntityManager()->remove($monitor);
$this->monitorRepository->getEntityManager()->flush(); $this->monitorRepository->getEntityManager()->flush();
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_DELETED->type(),
MonitorEvents::MONITOR_DELETED->message(),
(array) $monitor
));
return new DeleteMonitorResult( return new DeleteMonitorResult(
status: 'OK', status: 'OK',
result: [], result: [],

View File

@@ -6,10 +6,12 @@ use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator; use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\EventLog\Action\Command\AddEventLogCommand;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult; use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb; use App\Monitor\MonitorEvents;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory; use App\User\Dto\UserPreferencesFactory;
@@ -32,7 +34,7 @@ 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, private TmdbClient $tmdb,
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
) {} ) {}
@@ -42,13 +44,31 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode()); $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode()); $this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_STARTED->type(),
MonitorEvents::MONITOR_STARTED->message(),
(array) $command
));
if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) { $episodeData = $this->tmdb->tvEpisodeDetails($monitor->getTmdbId(), $monitor->getImdbId(), $monitor->getSeason(), $monitor->getEpisode());
$monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate));
if (null === $episodeData->airDate || "" === $episodeData->airDate) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode does not have an air date, skipping for now');
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'message' => 'No change',
'monitor' => $monitor,
]
);
} }
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) { if (null === $monitor->getAirDate()) {
$monitor->setAirDate(Carbon::parse($episodeData->airDate));
}
if (Carbon::createFromTimestamp($episodeData->airDate) > Carbon::today('UTC')) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now'); $this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult( return new MonitorTvEpisodeResult(
status: 'OK', status: 'OK',
@@ -105,12 +125,25 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage()); $this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage());
$this->logger->error($exception->getMessage()); $this->logger->error($exception->getMessage());
$monitor->setStatus('Active'); $monitor->setStatus('Active');
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_ERROR->type(),
MonitorEvents::MONITOR_ERROR->message() . ': ' . $exception->getMessage(),
(array) $monitor
));
} }
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->monitorRepository->getEntityManager()->flush(); $this->monitorRepository->getEntityManager()->flush();
$this->bus->dispatch(new AddEventLogCommand(
$monitor->getUser(),
MonitorEvents::MONITOR_FINISHED->type(),
MonitorEvents::MONITOR_FINISHED->message(),
(array) $monitor
));
return new MonitorTvEpisodeResult( return new MonitorTvEpisodeResult(
status: 'OK', status: 'OK',
result: [ result: [

View File

@@ -9,7 +9,7 @@ use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Result\MonitorTvSeasonResult; 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\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN; use App\Base\Util\PTN;
@@ -26,7 +26,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private LoggerInterface $logger, private LoggerInterface $logger,
private Tmdb $tmdb, private TmdbClient $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler, private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {} ) {}
@@ -50,7 +50,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
// 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->tvshowDetails($monitor->getImdbId())->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());

View File

@@ -9,9 +9,9 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvShowResult; use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb; use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbClient;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonImmutable;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN; use App\Base\Util\PTN;
@@ -29,8 +29,9 @@ readonly class MonitorTvShowHandler implements HandlerInterface
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler, private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
private MediaFiles $mediaFiles, private MediaFiles $mediaFiles,
private LoggerInterface $logger, private LoggerInterface $logger,
private Tmdb $tmdb, private TmdbClient $tmdb,
) {} ) {
}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
@@ -40,78 +41,77 @@ 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) => ->filter(fn($episode) => property_exists($episode, 'episode')
property_exists($episode, 'episode')
&& property_exists($episode, 'season') && property_exists($episode, 'season')
&& null !== $episode->episode && null !== $episode->episode
&& null !== $episode->season && 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->tvshowDetails($monitor->getImdbId())->episodes
)->flat(1); )->flat(1)
->filter(fn(TmdbEpisodeDto $episode) => $episode->seasonNumber >= $monitor->getSeason())
->values();
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle()); $this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle());
$episodeMonitors = []; $episodeMonitors = [];
if ($downloadedEpisodes->count() !== $episodesInShow->count()) { // Dispatch Episode commands for each missing Episode
// Dispatch Episode commands for each missing Episode foreach ($episodesInShow as $episode) {
foreach ($episodesInShow as $episode) { /** @var TmdbEpisodeDto $episode */
// Only monitor future episodes // Only monitor future episodes
$this->logger->info('> [MonitorTvShowHandler] Evaluating "' . $monitor->getTitle() . '", season "' . $episode['season_number'] . '" episode "' . $episode['episode_number'] . '"'); $this->logger->info('> [MonitorTvShowHandler] Evaluating "' . $monitor->getTitle() . '", season "' . $episode->seasonNumber . '" episode "' . $episode->episodeNumber . '"');
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode); $episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) { if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping'); $this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue; continue;
}
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] ...Episode exists? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue;
}
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Monitor exists? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue;
}
// Create the monitor
$episodeMonitor = (new Monitor())
->setParent($monitor)
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvepisode')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setAirDate($episode['air_date'] !== null && $episode['air_date'] !== "" ? Carbon::parse($episode['air_date']) : null)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush();
$episodeMonitors[] = $episodeMonitor;
// Immediately run the monitor
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvShowHandler] ...Dispatching MonitorTvEpisodeCommand');
} }
// Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] ...Episode exists? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue;
}
// Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Monitor exists? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue;
}
// Create the monitor
$episodeMonitor = (new Monitor())
->setParent($monitor)
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvepisode')
->setSeason($episode->seasonNumber)
->setEpisode($episode->episodeNumber)
->setAirDate($episode->airDate !== null && $episode->airDate !== "" ? Carbon::parse($episode->airDate) : null)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush();
$episodeMonitors[] = $episodeMonitor;
// Immediately run the monitor
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvShowHandler] ...Dispatching MonitorTvEpisodeCommand');
} }
// Set the status to Active, so it will be re-executed. // Set the status to Active, so it will be re-executed.
@@ -129,29 +129,31 @@ readonly class MonitorTvShowHandler implements HandlerInterface
); );
} }
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, array $episodeInShow): bool private function episodeReleasedAfterMonitorCreated(
{ string|DateTimeImmutable $monitorStartDate,
TmdbEpisodeDto $episodeInShow
): bool {
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0); $monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow['air_date']); $episodeAirDate = Carbon::parse($episodeInShow->airDate);
return $episodeAirDate >= $monitorStartDate; return $episodeAirDate >= $monitorStartDate;
} }
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool private function episodeExists(TmdbEpisodeDto $episodeInShow, Map $downloadedEpisodes): bool
{ {
return $downloadedEpisodes->filter( return $downloadedEpisodes->filter(
fn (object $episode) => $episode->episode === $episodeInShow['episode_number'] fn(object $episode) => $episode->episode === $episodeInShow->episodeNumber
&& $episode->season === $episodeInShow['season_number'] && $episode->season === $episodeInShow->seasonNumber
)->count() > 0; )->count() > 0;
} }
private function monitorExists(Monitor $monitor, array $episode): bool private function monitorExists(Monitor $monitor, TmdbEpisodeDto $episode): bool
{ {
return $this->monitorRepository->findOneBy([ return $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(), 'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(), 'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode', 'monitorType' => 'tvepisode',
'season' => $episode['season_number'], 'season' => $episode->seasonNumber,
'episode' => $episode['episode_number'], 'episode' => $episode->episodeNumber,
'status' => ['New', 'Active', 'In Progress'] 'status' => ['New', 'Active', 'In Progress']
]) !== null; ]) !== null;
} }

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

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

View File

@@ -68,7 +68,7 @@ class Monitor
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
private ?self $parent = null; private ?self $parent = null;
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')] #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent', cascade: ['remove'])]
private Collection $children; private Collection $children;
public function __construct() public function __construct()

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Monitor;
enum MonitorEvents
{
case MONITOR_ADDED;
case MONITOR_STARTED;
case MONITOR_FINISHED;
case MONITOR_DELETED;
case MONITOR_ERROR;
public function type(): string
{
return match ($this) {
self::MONITOR_ADDED => 'monitor_added',
self::MONITOR_STARTED => 'monitor_started',
self::MONITOR_FINISHED => 'monitor_finished',
self::MONITOR_DELETED => 'monitor_deleted',
self::MONITOR_ERROR => 'monitor_error',
};
}
public function message(): string
{
return match ($this) {
self::MONITOR_ADDED => 'A new monitor has been added.',
self::MONITOR_STARTED => 'A monitor has started.',
self::MONITOR_FINISHED => 'A monitor has finished.',
self::MONITOR_DELETED => 'A monitor has been deleted',
self::MONITOR_ERROR => 'A monitor has encountered an error.',
};
}
}

View File

@@ -2,9 +2,11 @@
namespace App\Search\Action\Handler; namespace App\Search\Action\Handler;
use App\Base\Enum\MediaType;
use App\Search\Action\Command\GetMediaInfoCommand; use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Result\GetMediaInfoResult; use App\Search\Action\Result\GetMediaInfoResult;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\CommandInterface; 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;
@@ -13,13 +15,30 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetMediaInfoHandler implements HandlerInterface class GetMediaInfoHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly TmdbClient $tmdb,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType); $handlers = [
MediaType::Movie->value => 'getMovieDetails',
MediaType::TvShow->value => 'getTvshowDetails',
];
$handler = $handlers[$command->mediaType];
$media = $this->$handler($command);
$relatedMedia = $this->tmdb->relatedMedia($media->tmdbId, $command->mediaType);
return new GetMediaInfoResult($media, $command->season, $command->episode); return new GetMediaInfoResult($media, $relatedMedia, $command->season, $command->episode);
}
private function getMovieDetails(CommandInterface $command): TmdbResult
{
return $this->tmdb->movieDetails($command->imdbId);
}
private function getTvshowDetails(CommandInterface $command): TmdbResult
{
$media = $this->tmdb->tvshowDetails($command->imdbId);
return $media;
} }
} }

View File

@@ -2,10 +2,10 @@
namespace App\Search\Action\Handler; namespace App\Search\Action\Handler;
use App\Base\Util\ImdbMatcher;
use App\Search\Action\Result\RedirectToMediaResult; use App\Search\Action\Result\RedirectToMediaResult;
use App\Search\Action\Result\SearchResult; use App\Search\Action\Result\SearchResult;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\CommandInterface; 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;
@@ -14,13 +14,13 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class SearchHandler implements HandlerInterface class SearchHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private Tmdb $tmdb, private TmdbClient $tmdb,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
if (ImdbMatcher::isMatch($command->term)) { $result = $this->tmdb->search($command->term);
$result = $this->tmdb->findByImdbId($command->term); if ($result instanceof TmdbResult) {
return new RedirectToMediaResult( return new RedirectToMediaResult(
imdbId: $result->imdbId, imdbId: $result->imdbId,
mediaType: $result->mediaType, mediaType: $result->mediaType,

View File

@@ -2,6 +2,7 @@
namespace App\Search\Action\Result; namespace App\Search\Action\Result;
use Aimeos\Map;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
@@ -10,6 +11,7 @@ class GetMediaInfoResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $media, public TmdbResult $media,
public Map|array $relatedMedia,
public ?int $season, public ?int $season,
public ?int $episode, public ?int $episode,
) {} ) {}

View File

@@ -2,6 +2,7 @@
namespace App\Search\Action\Result; namespace App\Search\Action\Result;
use Aimeos\Map;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<SearchResult> */ /** @implements ResultInterface<SearchResult> */
@@ -9,6 +10,6 @@ class SearchResult implements ResultInterface
{ {
public function __construct( public function __construct(
public string $term = "", public string $term = "",
public array $results = [] public Map|array $results = [],
) {} ) {}
} }

View File

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

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class CastMemberDto
{
public function __construct(
#[SerializedPath('[name]')]
public string $name,
) {}
public function __toString(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class CrewMemberDto
{
public function __construct(
#[SerializedPath('[name]')]
public string $name,
) {}
public function __toString(): string
{
return $this->name;
}
}

18
src/Tmdb/Dto/GenreDto.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class GenreDto
{
public function __construct(
public int $id,
public string $name,
) {}
public function __toString(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Tmdb\Dto;
use App\Base\Enum\MediaType;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class TmdbEpisodeDto
{
public function __construct(
#[SerializedPath('[id]')]
public ?int $tmdbId = null,
#[SerializedPath('[show_id]')]
public ?int $tmdbShowId = null,
public ?string $mediaType = MediaType::TvShow->value,
public ?string $imdbId = null,
public ?string $name = null,
#[SerializedPath('[air_date]')]
public ?string $airDate = null,
#[SerializedPath('[overview]')]
public ?string $description = null,
public ?string $poster = null,
public ?int $runtime = 0,
#[SerializedPath('[season_number]')]
public ?int $seasonNumber = 0,
#[SerializedPath('[episode_number]')]
public ?int $episodeNumber = 0,
) {}
}

View File

@@ -3,7 +3,7 @@
namespace App\Tmdb\Framework\Controller; namespace App\Tmdb\Framework\Controller;
use App\Base\Util\ImdbMatcher; use App\Base\Util\ImdbMatcher;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController class ApiController extends AbstractController
{ {
#[Route('/api/tmdb/ajax-search', name: 'api_tmdb_ajax_search', methods: ['GET'])] #[Route('/api/tmdb/ajax-search', name: 'api_tmdb_ajax_search', methods: ['GET'])]
public function test(Tmdb $tmdb, Request $request): Response public function test(TmdbClient $tmdb, Request $request): Response
{ {
$results = []; $results = [];
@@ -22,7 +22,7 @@ class ApiController extends AbstractController
if (null !== $term) { if (null !== $term) {
if (ImdbMatcher::isMatch($term)) { if (ImdbMatcher::isMatch($term)) {
$tmdbResult = $tmdb->findByImdbId($term); $tmdbResult = $tmdb->search($term);
$results = [ $results = [
[ [
'data' => $tmdbResult, 'data' => $tmdbResult,

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use App\Base\Enum\MediaType;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbMovieResultDenormalizer extends TmdbResultDenormalizer implements DenormalizerInterface
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {
parent::__construct($normalizer);
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
/** @var TmdbResult $result */
$result = parent::denormalize($data, TmdbResult::class, $format, $context);
if (array_key_exists('release_date', $data) && !in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']));
} else {
$airDate = null;
}
$result->title = $data['original_title'];
$result->premiereDate = $airDate;
$result->poster = (null !== $data['poster_path']) ? self::POSTER_IMG_PATH . $data['poster_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = MediaType::Movie->value;
return $result;
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return array_key_exists('media_type', $context) &&
$context['media_type'] === MediaType::Movie->value;
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbResultDenormalizer implements DenormalizerInterface
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
$result = $this->normalizer->denormalize($data, TmdbResult::class, $format, $context);
$result->stars = $this->getStars($data);
$result->directors = $this->getDirectors($data);
$result->producers = $this->getProducers($data);
$result->creators = $this->getCreators($data);
return $result;
}
protected function getStars(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['cast'])
->slice(0, 3)
->map(fn($item) => $this->normalizer->denormalize($item, CastMemberDto::class))
->toArray();
}
protected function getDirectors(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['crew'])
->filter(fn($item) => $item['job'] === 'Director')
->slice(0, 3)
->map(fn($item) => $this->normalizer->denormalize($item, CrewMemberDto::class))
->toArray();
}
public function getCreators(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['crew'])
->filter(fn($item) => $item['job'] === 'Creator')
->map(fn($item) => $this->normalizer->denormalize($item, CrewMemberDto::class))
->toArray();
}
public function getProducers(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['crew'])
->filter(fn($item) => $item['job'] === 'Producer')
->map(fn($item) => $this->normalizer->denormalize($item, CrewMemberDto::class))
->toArray();
}
public function getGenres(array $data, MediaType $mediaType): ?array
{
if (array_key_exists('genres', $data)) {
return null;
}
return Map::from($data['genres'])
->map(fn($item) => $this->normalizer->denormalize($item, GenreDto::class))
->toArray();
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return !array_key_exists('media_type', $context);
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use App\Base\Enum\MediaType;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbTvEpisodeResultDenormalizer implements DenormalizerInterface
{
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|TmdbEpisodeDto|array|null
{
/** @var TmdbEpisodeDto $result */
$result = $this->normalizer->denormalize($data, TmdbEpisodeDto::class, $format, $context);
return $result;
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return array_key_exists('media_type', $context) &&
$context['media_type'] === MediaType::TvEpisode->value;
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use App\Base\Enum\MediaType;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbTvShowResultDenormalizer extends TmdbResultDenormalizer implements DenormalizerInterface
{
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {
parent::__construct($normalizer);
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
/** @var TmdbResult $result */
$result = parent::denormalize($data, TmdbResult::class, $format, $context);
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']));
} else {
$airDate = null;
}
if (array_key_exists('seasons', $data)) {
$result->numberSeasons = count($data['seasons']);
}
$result->title = $data['original_name'];
$result->premiereDate = $airDate;
$result->poster = (null !== $data['poster_path']) ? self::POSTER_IMG_PATH . $data['poster_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = MediaType::TvShow->value;
if (is_array($result->episodes)) {
$result->latestSeason = array_key_last($result->episodes);
}
return $result;
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return array_key_exists('media_type', $context) &&
$context['media_type'] === MediaType::TvShow->value;
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -1,423 +0,0 @@
<?php
namespace App\Tmdb;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\ValueObject\ResultFactory;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Model\Movie;
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Repository\TvSeasonRepository;
use Tmdb\Token\Api\ApiToken;
use Tmdb\Token\Api\BearerToken;
class Tmdb
{
protected Client $client;
protected MovieRepository $movieRepository;
protected TvRepository $tvRepository;
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
) {
$this->client = new Client(
[
/** @var ApiToken|BearerToken */
'api_token' => new BearerToken($apiKey),
'secure' => true,
'base_uri' => Client::TMDB_URI,
'event_dispatcher' => [
'adapter' => $this->eventDispatcher,
],
// We make use of PSR-17 and PSR-18 auto discovery to automatically guess these, but preferably set these explicitly.
'http' => [
'client' => null,
'request_factory' => null,
'response_factory' => null,
'stream_factory' => null,
'uri_factory' => null,
],
'hydration' => [
'event_listener_handles_hydration' => false,
'only_for_specified_models' => []
]
]
);
/**
* Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
*/
$requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(),
$this->eventDispatcher,
$cache,
$this->client->getHttpClient()->getPsr17StreamFactory(),
[]
);
$this->eventDispatcher->addListener(RequestEvent::class, $requestListener);
$apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $apiTokenListener);
$acceptJsonListener = new AcceptJsonRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $acceptJsonListener);
$jsonContentTypeListener = new ContentTypeJsonRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $jsonContentTypeListener);
$userAgentListener = new UserAgentRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener);
$this->movieRepository = new MovieRepository($this->client);
$this->tvRepository = new TvRepository($this->client);
}
public function popularMovies(int $page = 1, ?int $limit = null)
{
$movies = $this->movieRepository->getPopular(['page' => $page]);
$movies = $movies->map(function ($movie) use ($movies) {
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());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;
}
public function popularTvShows(int $page = 1, ?int $limit = null)
{
$movies = $this->tvRepository->getPopular(['page' => $page]);
$movies = $movies->map(function ($movie) use ($movies) {
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());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;
}
public function search(string $term, int $page = 1)
{
$searchRepository = new SearchRepository($this->client);
$searchResults = $searchRepository->searchMulti($term, new KeywordSearchQuery(['page' => $page]));
$results = [];
foreach ($searchResults as $result) {
if (!$result instanceof Movie && !$result instanceof Tv) {
continue;
}
$results[] = $this->parseResult($result);
}
$results = array_filter($results, fn ($result) => null !== $result->imdbId);
return $results;
}
public function find(string $id)
{
$finder = new Find($this->client);
$result = $finder->findBy($id, ['external_source' => 'imdb_id']);
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 findByImdbId(string $imdbId)
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
$result = $result['movie_results'][0];
$mediaType = MediaType::Movie->value;
} elseif (count($result['tv_results']) > 0) {
$result = $result['tv_results'][0];
$mediaType = MediaType::TvShow->value;
} elseif (count($result['tv_episode_results']) > 0) {
$result = $result['tv_episode_results'][0];
$mediaType = MediaType::TvShow->value;
}
$result['media_type'] = $mediaType;
$result = $this->mediaDetails($imdbId, $result['media_type']);
return $result;
}
public function movieDetails(string $id)
{
$client = new MovieRepository($this->client);
$details = $client->getApi()->getMovie($id, ['append_to_response' => 'external_ids']);
return $this->parseResult($details, "movie");
}
public function tvDetails(string $id)
{
$client = new TvRepository($this->client);
$details = $client->getApi()->getTvshow($id, ['append_to_response' => 'external_ids,seasons']);
$details = $this->getEpisodesFromSeries($details);
return $this->parseResult($details, "tvshow");
}
public function episodeDetails(string $id, string $season, string $episode)
{
$client = new TvEpisodeRepository($this->client);
$result = $client->getApi()->getEpisode($id, $season, $episode, ['append_to_response' => 'external_ids']);
return $this->parseResult($result, "episode");
}
public function getEpisodesFromSeries(array $series)
{
$client = new TvSeasonRepository($this->client);
foreach ($series['seasons'] as $season) {
if ($season['episode_count'] <= 0 || $season['name'] === 'Specials') {
continue;
}
$series['episodes'][$season['season_number']] = Map::from(
$client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']
)->map(function ($data) {
$data['poster'] = (null !== $data['still_path']) ? self::POSTER_IMG_PATH . $data['still_path'] : null;
return $data;
})->toArray();
}
return $series;
}
public function mediaDetails(string $id, string $type)
{
$id = $this->find($id);
if ($type === "movies") {
return $this->movieDetails($id);
} else {
return $this->tvDetails($id);
}
}
private function parseResult($result, $mediaType = null)
{
if (is_array($result)) {
return $this->parseFromArray($result, $mediaType);
} else {
return $this->parseFromObject($result);
}
}
private function parseFromArray($data, $mediaType)
{
if (null === $mediaType) {
throw new \Exception("A media type must be set when parsing from an array.");
}
if ($mediaType === 'movie') {
$result = $this->parseMovie($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'tvshow') {
$result = $this->parseTvShow($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'episode') {
$result = $this->parseEpisode($data, self::POSTER_IMG_PATH);
}
return $result;
}
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: $airDate,
mediaType: "tvshows",
episodes: $data['episodes'],
);
}
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['air_date'], ['', null,])) {
$airDate = (new \DateTime($data['air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['still_path']) ? $posterBasePath . $data['still_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
episodeAirDate: $airDate,
);
}
private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['title'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: $airDate,
);
}
private function parseFromObject($result): TmdbResult
{
$mediaType = $result instanceof Movie ? MediaType::Movie->value : MediaType::TvShow->value;
$tmdbResult = new TmdbResult();
$tmdbResult->mediaType = $mediaType;
$tmdbResult->tmdbId = $result->getId();
$tmdbResult->imdbId = $this->getImdbId($result->getId(), $mediaType);
$tmdbResult->title = $this->getTitle($result, $mediaType);
$tmdbResult->poster = self::POSTER_IMG_PATH . $result->getPosterImage();
$tmdbResult->year = $this->getReleaseDate($result, $mediaType);
$tmdbResult->description = $result->getOverview();
return $tmdbResult;
}
public function getImdbId(string $tmdbId, $mediaType)
{
$externalIds = $this->cache->get("tmdb.externalIds.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return $this->movieRepository->getExternalIds($tmdbId);
case MediaType::TvShow->value:
return $this->tvRepository->getExternalIds($tmdbId);
default:
return null;
}
});
if (null === $externalIds) {
return null;
}
return $externalIds->getImdbId() !== "" ? $externalIds->getImdbId() : "null";
}
public function getImages($tmdbId, $mediaType)
{
return $this->cache->get("tmdb.images.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return $this->movieRepository->getImages($tmdbId);
case MediaType::TvShow->value:
return $this->tvRepository->getImages($tmdbId);
default:
return null;
}
});
}
private function getReleaseDate($result, $mediaType): string
{
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return ($result->getReleaseDate() instanceof \DateTime)
? $result->getReleaseDate()->format('Y')
: $result->getReleaseDate();
case MediaType::TvShow->value:
return ($result->getFirstAirDate() instanceof \DateTime)
? $result->getFirstAirDate()->format('Y')
: $result->getFirstAirDate();
default:
return "";
}
}
private function getTitle($result, $mediaType): string
{
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return $result->getTitle();
case MediaType::TvShow->value:
return $result->getName();
default:
return "";
}
}
}

276
src/Tmdb/TmdbClient.php Normal file
View File

@@ -0,0 +1,276 @@
<?php
namespace App\Tmdb;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Repository\TvSeasonRepository;
use Tmdb\Token\Api\ApiToken;
use Tmdb\Token\Api\BearerToken;
class TmdbClient
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
protected Client $client;
protected MovieRepository $movieRepository;
protected TvRepository $tvRepository;
protected TvSeasonRepository $tvSeasonRepository;
protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository;
protected array $mediaTypeMap = [
MediaType::Movie->value => MediaType::Movie->value,
MediaType::TvShow->value => MediaType::TvShow->value,
MediaType::TvEpisode->value => MediaType::TvEpisode->value,
'movie' => 'movies',
'tv' => 'tvshows',
];
protected $repos = [];
public function __construct(
private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
) {
$this->client = new Client(
[
/** @var ApiToken|BearerToken */
'api_token' => new BearerToken($apiKey),
'secure' => true,
'base_uri' => Client::TMDB_URI,
'event_dispatcher' => [
'adapter' => $this->eventDispatcher,
],
// We make use of PSR-17 and PSR-18 auto discovery to automatically guess these, but preferably set these explicitly.
'http' => [
'client' => null,
'request_factory' => null,
'response_factory' => null,
'stream_factory' => null,
'uri_factory' => null,
],
'hydration' => [
'event_listener_handles_hydration' => false,
'only_for_specified_models' => []
]
]
);
/**
* Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
*/
$requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(),
$this->eventDispatcher,
$cache,
$this->client->getHttpClient()->getPsr17StreamFactory(),
[]
);
$this->eventDispatcher->addListener(RequestEvent::class, $requestListener);
$apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $apiTokenListener);
$acceptJsonListener = new AcceptJsonRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $acceptJsonListener);
$jsonContentTypeListener = new ContentTypeJsonRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $jsonContentTypeListener);
$userAgentListener = new UserAgentRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener);
$this->movieRepository = new MovieRepository($this->client);
$this->tvRepository = new TvRepository($this->client);
$this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client);
$this->repos = [
MediaType::Movie->value => $this->movieRepository,
MediaType::TvShow->value => $this->tvRepository,
MediaType::TvEpisode->value => $this->tvEpisodeRepository,
];
}
public function search(string $term): TmdbResult|Map
{
if (ImdbMatcher::isMatch($term)) {
$handlers = [
'movie' => 'movieDetails',
'tvshow' => 'tvshowDetails',
];
$data = $this->findByImdbId($term);
$handler = $handlers[$data['media_type']];
return $this->$handler($term);
}
$results = $this->searchRepository->getApi()->searchMulti($term);
return $this->parseListOfResults($results);
}
public function movieDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
return $this->parseResult(
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids,credits']),
MediaType::Movie->value,
$imdbId
);
}
public function tvshowDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids,credits']);
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 &&
strtolower($data['name']) !== 'specials' &&
$data['episode_count'] > 0;
})->map(function ($data) use ($media) {
return $this->tvSeasonDetails($media['id'], $data['season_number'])['episodes'];
})->rekey(function ($data) use ($media) {
return $data[1]['season_number'];
})->toArray();
return $this->parseResult(
$media,
MediaType::TvShow->value,
$imdbId
);
}
public function tvSeasonDetails(string $tmdbId, int $season): array
{
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => 'external_ids,credits']);
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path'];
return $data;
})->rekey(fn ($data) => $data['episode_number'])->toArray();
return $result;
}
public function tvEpisodeDetails(string $tmdbId, string $showImdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null
{
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => 'external_ids,credits']);
return $this->parseResult(
$result,
MediaType::TvEpisode->value,
$showImdbId
);
}
public function relatedMedia(string $tmdbId, string $mediaType, int $resultCount = 6): Map
{
$results = $this->repos[$mediaType]->getApi()->getRecommendations($tmdbId, ['append_to_response' => 'external_ids']);
return $this->parseListOfResults(
$results,
$resultCount
);
}
public function popularMovies(int $resultCount = 6): Map
{
$results = $this->movieRepository->getApi()->getPopular();
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value;
return $result;
});
return $this->parseListOfResults(
$results,
$resultCount
);
}
public function popularTvShows(int $resultCount = 6): Map
{
$results = $this->tvRepository->getApi()->getPopular();
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value;
return $result;
});
return $this->parseListOfResults(
$results,
$resultCount
);
}
private function getExternalIds(int $tmdbId, string $mediaType): ?array
{
if (!array_key_exists($mediaType, $this->repos) ||
!in_array($mediaType, [MediaType::Movie->value, MediaType::TvShow->value])) {
return [];
}
return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
}
private function findByImdbId(string $imdbId): array
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
return $result['movie_results'][0];
} elseif (count($result['tv_results']) > 0) {
return $result['tv_results'][0];
} elseif (count($result['tv_episode_results']) > 0) {
return $result['tv_episode_results'][0];
}
throw new \Exception("No results found for $imdbId");
}
private function parseResult(array $data, string $mediaType, string $imdbId): TmdbResult|TmdbEpisodeDto
{
if (!array_key_exists('external_ids', $data)) {
$data['external_ids'] = ['imdb_id' => $imdbId];
}
return $this->serializer->denormalize($data, TmdbResult::class, context: ['media_type' => $mediaType]);
}
private function parseListOfResults(array $data, ?int $resultCount = null): Map
{
$results = Map::from($data['results'])->filter(function ($result) {
return array_key_exists('media_type', $result) &&
in_array($result['media_type'], array_keys($this->mediaTypeMap));
})->map(function ($result) {
$result['external_ids'] = $this->getExternalIds($result['id'], $this->mediaTypeMap[$result['media_type']]);
return $result;
})->filter(function ($result) {
return array_key_exists('id', $result) &&
array_key_exists('imdb_id', $result['external_ids']) &&
$result['external_ids']['imdb_id'] !== null &&
$result['external_ids']['imdb_id'] !== "";
})->map(function ($result) {
return $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => $this->mediaTypeMap[$result['media_type']]]);
});
if (null !== $resultCount) {
$results = $results->slice(0, $resultCount);
}
return $results;
}
}

View File

@@ -2,17 +2,60 @@
namespace App\Tmdb; namespace App\Tmdb;
use App\Base\Enum\MediaType;
use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class TmdbResult class TmdbResult
{ {
/**
* @param string|null $imdbId
* @param int|null $tmdbId
* @param string|null $title
* @param string|null $description
* @param string|null $poster
* @param \DateTimeInterface|null $premiereDate
* @param string|null $year
* @param string|null $mediaType
* @param array<TmdbEpisodeDto[]>|null $episodes
* @param string|null $episodeAirDate
* @param GenreDto[]|null $genres
* @param CastMemberDto[]|null $stars
* @param CrewMemberDto[]|null $directors
* @param CrewMemberDto[]|null $creators
* @param CrewMemberDto[]|null $producers
* @param int|null $runtime
* @param int|null $numberSeasons
*/
public function __construct( public function __construct(
#[SerializedPath('[external_ids][imdb_id]')]
public ?string $imdbId = "", public ?string $imdbId = "",
public ?string $tmdbId = "", #[SerializedPath('[id]')]
public ?int $tmdbId = null,
public ?string $title = "", public ?string $title = "",
public ?string $poster = "", #[SerializedPath('[overview]')]
public ?string $description = "", public ?string $description = "",
public ?string $year = "", public ?string $poster = "",
public ?\DateTimeInterface $premiereDate = null,
public ?string $year = null,
public ?string $mediaType = "", public ?string $mediaType = "",
#[Context(denormalizationContext: [
'media_type' => MediaType::TvEpisode->value
])]
#[SerializedPath('[seasons]')]
public ?array $episodes = null, public ?array $episodes = null,
public ?string $episodeAirDate = null, public ?string $episodeAirDate = null,
public ?array $genres = null,
public ?array $stars = null,
public ?array $directors = null,
public ?array $creators = null,
public ?array $producers = null,
public ?int $runtime = null,
public ?int $numberSeasons = null,
public ?int $latestSeason = null,
) {} ) {}
} }

View File

@@ -3,7 +3,7 @@
namespace App\Torrentio\Action\Handler; namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Result\GetMovieOptionsResult; use App\Torrentio\Action\Result\GetMovieOptionsResult;
use App\Torrentio\Client\Torrentio; use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
@@ -13,14 +13,14 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetMovieOptionsHandler implements HandlerInterface class GetMovieOptionsHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly TmdbClient $tmdb,
private readonly Torrentio $torrentio, private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles private readonly MediaFiles $mediaFiles
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$media = $this->tmdb->mediaDetails($command->imdbId, 'movies'); $media = $this->tmdb->movieDetails($command->imdbId);
return new GetMovieOptionsResult( return new GetMovieOptionsResult(
media: $media, media: $media,
file: $this->mediaFiles->movieExists($media->title), file: $this->mediaFiles->movieExists($media->title),

View File

@@ -2,9 +2,10 @@
namespace App\Torrentio\Action\Handler; namespace App\Torrentio\Action\Handler;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto; use App\Library\Dto\MediaFileDto;
use App\Tmdb\Tmdb; use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Torrentio\Client\Torrentio; use App\Torrentio\Client\Torrentio;
@@ -16,15 +17,15 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsHandler implements HandlerInterface class GetTvShowOptionsHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly TmdbClient $tmdb,
private readonly Torrentio $torrentio, private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles, 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); $media = $this->tmdb->tvEpisodeDetails($command->tmdbId, $command->imdbId, $command->season, $command->episode);
$parentShow = $this->tmdb->mediaDetails($command->imdbId, 'tvshows'); $parentShow = $this->tmdb->tvshowDetails($command->imdbId);
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode); $file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult( return new GetTvShowOptionsResult(

View File

@@ -3,14 +3,15 @@
namespace App\Torrentio\Action\Result; namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto; use App\Library\Dto\MediaFileDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $parentShow, public TmdbResult|TmdbEpisodeDto $parentShow,
public TmdbResult $media, public TmdbResult|TmdbEpisodeDto $media,
public MediaFileDto|false $file, public MediaFileDto|false $file,
public string $season, public string $season,
public string $episode, public string $episode,

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Torrentio\Client;
use Carbon\Carbon;
use GuzzleHttp\Client as GuzzleClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class HttpClient
{
private GuzzleClient $client;
private string $baseUrl = 'https://torrentio.strem.fun/realdebrid=%s/';
public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
) {
$this->client = new GuzzleClient([
'base_uri' => sprintf($this->baseUrl, $this->realDebridKey),
]);
}
public function get(string $imdbId, array $cacheTags = []): array
{
$cacheKey = str_replace(":", ".", "torrentio.{$imdbId}");
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $cacheTags) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
if (count($cacheTags) > 0) {
$item->tag($cacheTags);
}
try {
$response = $this->client->get("stream/movie/$imdbId.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
dd($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 [];
});
}
}

View File

@@ -2,62 +2,22 @@
namespace App\Torrentio\Client; namespace App\Torrentio\Client;
use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\Result\ResultFactory; use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
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 $searchUrl;
private Client $client;
public function __construct( public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey, private readonly HttpClient $client,
private TagAwareCacheInterface $cache, ) {}
private LoggerInterface $logger,
) {
$this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
$this->client = new Client([
'base_uri' => $this->searchUrl,
]);
}
public function search(string $imdbCode, string $type, bool $parseResults = true): array public function search(string $imdbCode, string $type, bool $parseResults = true): array
{ {
$cacheKey = "torrentio.{$imdbCode}"; $cacheTags = ['torrentio', $type, $imdbCode];
$results = $this->client->get($imdbCode, $cacheTags);
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['torrentio', $type, $imdbCode]);
try {
$response = $this->client->get("$this->searchUrl/$imdbCode.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
if (true === $parseResults) { if (true === $parseResults) {
return $this->parse($results); return $this->parse($results, $imdbCode);
} }
return $results; return $results;
@@ -65,39 +25,21 @@ class Torrentio
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array
{ {
$cacheKey = "torrentio.$imdbId.$season.$episode"; $cacheTags = ['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"];
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) { $results = $this->client->get("$imdbId:$season:$episode", $cacheTags);
$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"]);
try {
$response = $this->client->get("$this->searchUrl/$imdbId:$season:$episode.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
if (null === $results) { if (null === $results) {
throw new TorrentioRateLimitException(); throw new TorrentioRateLimitException();
} }
if (true === $parseResults) { if (true === $parseResults) {
return $this->parse($results); return $this->parse($results, $imdbId);
} }
return $results; return $results;
} }
public function parse(array $data): array public function parse(array $data, string $imdbId): array
{ {
$results = []; $results = [];
foreach ($data['streams'] as $stream) { foreach ($data['streams'] as $stream) {
@@ -117,7 +59,8 @@ class Torrentio
$result = ResultFactory::map( $result = ResultFactory::map(
$stream['url'], $stream['url'],
$stream['title'], $stream['title'],
$bingeGroup $bingeGroup,
$imdbId
); );
$results[] = $result; $results[] = $result;

View File

@@ -18,30 +18,35 @@ class ResultFactory
public static function map( public static function map(
string $url, string $url,
string $title, string $title,
string $bingeGroup = "-" string $bingeGroup = "-",
string $imdbId = "-",
) { ) {
$ptn = (object) (new PTN())->parse($title); $title = trim(preg_replace('/\s+/', ' ', $title));
return new TorrentioResult( $ptn = (object) new PTN()->parse(self::setFilename($url));
$result = new TorrentioResult(
self::trimTitle($title), self::trimTitle($title),
urldecode($url), self::setUrl($url),
self::setFilename($url), self::setFilename($url),
self::setSize($title), self::setSize($title),
self::setSeeders($title), self::setSeeders($title),
self::setProvider($title), self::setProvider($title),
self::setEpisode($title), self::setEpisode($title),
$ptn->season ?? "-", self::setSeason($ptn),
$bingeGroup, $bingeGroup,
$ptn->resolution ?? "-", self::setResolution($ptn),
self::setCodec($ptn->codec ?? "-"), self::setCodec($ptn),
$ptn->quality ?? "-", self::setQuality($ptn),
$ptn, $ptn,
substr(base64_encode($url), strlen($url) - 10), self::setKey($url),
$ptn->episode ?? "-", self::setEpisodeNumber($ptn),
self::setLanguages($title), self::setLanguages($title),
self::setLanguageFlags($title), self::setLanguageFlags($title),
false, false,
uniqid() uniqid(),
$imdbId,
); );
return $result;
} }
public static function setFilename(string $url) public static function setFilename(string $url)
@@ -50,6 +55,11 @@ class ResultFactory
return end($file); return end($file);
} }
public static function setUrl(string $url): string
{
return urldecode($url);
}
public static function setSize(string $title): string public static function setSize(string $title): string
{ {
$sizeMatch = []; $sizeMatch = [];
@@ -110,9 +120,15 @@ class ResultFactory
} }
} }
public static function setCodec(string $codec): string public static function setCodec(object $ptn): string
{ {
return self::$codecMap[strtolower($codec)] ?? $codec; if (isset($ptn->codec) && array_key_exists($ptn->codec, self::$codecMap)) {
return self::$codecMap[strtolower($ptn->codec)];
} elseif (isset($ptn->codec)) {
return $ptn->codec;
}
return "-";
} }
private static function setEpisode(string $title) private static function setEpisode(string $title)
@@ -122,6 +138,36 @@ class ResultFactory
return array_key_exists(0, $value) ? strtoupper($value[0]) : "n/a"; return array_key_exists(0, $value) ? strtoupper($value[0]) : "n/a";
} }
public static function setSeason(object $ptn): string
{
return $ptn->season ?? "-";
}
public static function setBingeGroup(string $bingeGroup): string
{
return $bingeGroup;
}
public static function setResolution(object $ptn): string
{
return $ptn->resolution ?? "-";
}
public static function setQuality(object $ptn): string
{
return $ptn->quality ?? "-";
}
public static function setKey(string $url): string
{
return substr(base64_encode($url), strlen($url) - 10);
}
public static function setEpisodeNumber(object $ptn): string
{
return $ptn->episode ?? "-";
}
private static function trimTitle(string $title) private static function trimTitle(string $title)
{ {
$emoji = \Emoji\detect_emoji($title); $emoji = \Emoji\detect_emoji($title);

View File

@@ -23,6 +23,7 @@ class TorrentioResult
public ?array $languages = [], public ?array $languages = [],
public ?string $languageFlags = "-", public ?string $languageFlags = "-",
public ?bool $selected = false, public ?bool $selected = false,
public ?string $localId = "-" public ?string $localId = "-",
public ?string $imdbId = "-",
) {} ) {}
} }

View File

@@ -49,7 +49,6 @@ final class TvEpisodeList
} }
$this->reloadCount++; $this->reloadCount++;
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage); return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
} }

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

@@ -2,10 +2,13 @@
namespace App\Twig\Extensions; namespace App\Twig\Extensions;
use App\Base\ConfigResolver;
use App\Base\Dto\AppVersionDto;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto; use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize; use ChrisUllyott\FileSize;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Twig\Attribute\AsTwigFilter; use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigFunction;
@@ -13,9 +16,16 @@ class UtilExtension
{ {
public function __construct( public function __construct(
private readonly ConfigResolver $config,
private readonly MediaFiles $mediaFiles, private readonly MediaFiles $mediaFiles,
) {} ) {}
#[AsTwigFunction('app_version')]
public function app_version(): AppVersionDto
{
return $this->config->getAppVersion();
}
#[AsTwigFunction('uniqid')] #[AsTwigFunction('uniqid')]
public function uniqid(): string public function uniqid(): string
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\User\Framework\Entity;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\EventLog\Framework\Entity\EventLog;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -56,11 +57,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')] #[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')]
private Collection $downloads; private Collection $downloads;
/**
* @var Collection<int, EventLog>
*/
#[ORM\OneToMany(targetEntity: EventLog::class, mappedBy: 'user')]
private Collection $eventLogs;
public function __construct() public function __construct()
{ {
$this->userPreferences = new ArrayCollection(); $this->userPreferences = new ArrayCollection();
$this->monitors = new ArrayCollection(); $this->monitors = new ArrayCollection();
$this->downloads = new ArrayCollection(); $this->downloads = new ArrayCollection();
$this->eventLogs = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -327,4 +335,49 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
} }
return []; return [];
} }
public function getCalendarPreferences(): array
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->filter(fn(UserPreference $userPreference) => $userPreference->getPreference()->getType() === 'calendar')
->toArray()
;
}
public function hasICalEnabled(): bool
{
return $this->hasUserPreference('enable_ical_up_ep') &&
(bool) $this->getUserPreference('enable_ical_up_ep')->getPreferenceValue() === true;
}
/**
* @return Collection<int, EventLog>
*/
public function getEventLogs(): Collection
{
return $this->eventLogs;
}
public function addEventLog(EventLog $eventLog): static
{
if (!$this->eventLogs->contains($eventLog)) {
$this->eventLogs->add($eventLog);
$eventLog->setUser($this);
}
return $this;
}
public function removeEventLog(EventLog $eventLog): static
{
if ($this->eventLogs->removeElement($eventLog)) {
// set the owning side to null (unless already changed)
if ($eventLog->getUser() === $this) {
$eventLog->setUser(null);
}
}
return $this;
}
} }

View File

@@ -20,7 +20,7 @@
{% block body %}{% endblock %} {% block body %}{% endblock %}
<div class="mt-2 inline-flex gap-4 justify-between text-white"> <div class="mt-2 inline-flex gap-4 justify-between text-white">
<a class="text-sm" href="{{ path('app_login') }}">Sign In</a> <a class="text-sm" href="{{ path('app_login') }}">Sign In</a>
<span class="text-sm">v{{ version }}</span> <span class="text-sm">{{ app_version() }}</span>
</div> </div>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,17 @@
{% extends 'bare.html.twig' %}
{% block body %}
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">{{ }}</h2>
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<div class="flex flex-col m-0 text-center">
<h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3>
<small>(or is it?)</small>
</div>
<p class="mb-2">There are many things I'm capable of, but this ain't one of 'em!</p>
<pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto">
<code class="language-plaintext">
{{ exception.message }}
</code>
</pre>
</div>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'bare.html.twig' %}
{% block body %}
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">404</h2>
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
<div class="flex flex-col m-0 text-center">
<h3 class="text-2xl text-bold text-center text-gray-50">It's not you, it's me!</h3>
<small>(or is it?)</small>
</div>
<p class="mb-2">I don't know, maybe I used to have that page-maybe I didn't, but one thing's for sure: I
don't have it now. Sorry!</p>
</div>
{% endblock %}

View File

@@ -39,15 +39,9 @@
</div> </div>
{{ form_end(preferences_form) }} {{ form_end(preferences_form) }}
<div class="flex flex-col md:flex-row justify-between"> <div class="flex flex-col-reverse md:flex-row justify-between">
<span
{{ stimulus_target('result-filter', 'loadingIcon') }}
{{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
class="loading-icon">
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
</span>
{% if results.media.mediaType == "tvshows" %} {% if results.media.mediaType == "tvshows" %}
<p class="ml-2 mt-3 md:[margin-top:unset] md:self-center">Season <span data-result-filter-target="currentSeason" class="current-season">{{ results.season }}</span></p>
<div class="flex flex-row gap-2 justify-end px-8"> <div class="flex flex-row gap-2 justify-end px-8">
<twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 border border-green-500 bg-green-800/60 rounded-ms text-sm font-semibold" show_cancel show_submit> <twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 border border-green-500 bg-green-800/60 rounded-ms text-sm font-semibold" show_cancel show_submit>
Downloading an entire season this way will use the filter from your Downloading an entire season this way will use the filter from your

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