Compare commits

...

75 Commits

Author SHA1 Message Date
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
b587302b30 wip: ical calendar export 2025-08-26 22:24:23 -05:00
e5bab8e6fd fix: adds calendar button to link to upcoming episodes, adds titles to A tags 2025-08-25 23:06:30 -05:00
502b85dda4 fix: typo in default NTFY_DSN env var 2025-08-24 13:08:20 -05:00
9c430290e9 fix: makes calendar responsive 2025-08-23 22:18:01 -05:00
583591bf4f fix: applies colors to calendar events 2025-08-23 21:43:44 -05:00
182708b8f0 fix: links to upcoming episodes page 2025-08-23 14:54:04 -05:00
d6ba4d7d2a fix: updates episode air date for existing monitors 2025-08-23 14:37:24 -05:00
e5c5ec93a8 feat: /api/upcoming-episodes 2025-08-23 14:14:37 -05:00
942911d8ef fix: removes fullcalendar from importmap and references from cdn 2025-08-23 12:26:26 -05:00
2f7d406d12 wip: renders calendar with demo data 2025-08-23 09:22:02 -05:00
4e1adc576c fix: disables pull to refresh 2025-08-09 00:37:13 -05:00
93 changed files with 1890 additions and 939 deletions

14
.dockerignore Normal file
View File

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

2
.env
View File

@@ -57,4 +57,4 @@ OIDC_BYPASS_FORM_LOGIN=false
###< symfony/ntfy-notifier ###
NOTIFICATION_TRANSPORT=
NTFY_DNS=
NTFY_DSN=

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 CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apt update && apt install -y wget
ENV APP_VERSION="0.0.0-dev"
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

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

View File

@@ -13,34 +13,7 @@ export default class extends Controller {
tmdbId: String,
imdbId: String,
title: String,
}
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)
season: Number,
}
toggle() {
@@ -53,34 +26,13 @@ export default class extends Controller {
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvshows',
season: this.seasonValue
});
if (this.hasDialogOutlet) {
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) {
const response = await fetch('/api/monitor', {
method: 'POST',
@@ -90,7 +42,6 @@ export default class extends Controller {
},
body: JSON.stringify(body)
});
return await response.json();
}
}

View File

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

View File

@@ -22,7 +22,7 @@ export default class extends Controller {
defaultOptions = '<option value="-">-</option>';
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 = {
'imdbId': String,
'media-type': String,
@@ -32,7 +32,6 @@ export default class extends Controller {
async connect() {
await this.setInitialFilter();
this.setTimerToStopLoadingIcon();
this.element.filterResults = this.filter.bind(this);
document.addEventListener('optionsLoaded', this.loadOptions.bind(this));
}
@@ -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
async loadOptions({detail: { options }}) {
await options.forEach((option) => {
@@ -99,7 +94,9 @@ export default class extends Controller {
}
setSeason(event) {
console.log('hurrrr');
this.tvEpisodeListOutlet.setSeason(event.target.value);
this.currentSeasonTarget.innerText = event.target.value;
}
downloadSeason() {

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 529 B

View File

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

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -27,6 +27,11 @@
}
}
:root {
--fc-border-color: #a65b27;
--fc-page-bg-color: #a65b27;
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
@@ -193,3 +198,11 @@ dialog[data-dialog-target="dialog"][closing] {
.filter-label {
@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 {
@apply bg-orange-500/60 text-white;
}

14
bash/build_base.sh Executable file
View File

@@ -0,0 +1,14 @@
# torsearch-app is built from this base
export APP_FRANKENPHP_TAG=php8.4
docker buildx build --platform=linux/amd64 -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 -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

View File

@@ -33,7 +33,11 @@ services:
worker:
build: .
build:
dockerfile: docker/Dockerfile.base.worker
context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped
volumes:
- $PWD:/app
@@ -41,11 +45,15 @@ services:
tty: true
environment:
TZ: America/Chicago
command: php /app/bin/console messenger:consume async -vv --time-limit=3600
command: php /app/bin/console messenger:consume async --time-limit=3600 -vv
scheduler:
build: .
build:
dockerfile: docker/Dockerfile.base.worker
context: .
args:
FRANKENPHP_TAG: php8.4-alpine
restart: unless-stopped
volumes:
- $PWD:/app

View File

@@ -25,8 +25,11 @@
"p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"spatie/icalendar-generator": "^3.0",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
@@ -45,9 +48,13 @@
"symfony/messenger": "7.3.*",
"symfony/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/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27",

134
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6f57ba35ae317ec6370836bda0012db8",
"content-hash": "e055bbbbe5836c92bb147b6dbb1d1d46",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -4757,6 +4757,65 @@
],
"time": "2023-12-12T12:06:11+00:00"
},
{
"name": "spatie/icalendar-generator",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/icalendar-generator.git",
"reference": "32797f6e5afa3142d073f38d5f22ab377f4d8f90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/icalendar-generator/zipball/32797f6e5afa3142d073f38d5f22ab377f4d8f90",
"reference": "32797f6e5afa3142d073f38d5f22ab377f4d8f90",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^8.1"
},
"require-dev": {
"ext-json": "*",
"larapack/dd": "^1.1",
"nesbot/carbon": "^3.5",
"pestphp/pest": "^2.34",
"phpstan/phpstan": "^2.0",
"spatie/pest-plugin-snapshots": "^2.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\IcalendarGenerator\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ruben Van Assche",
"email": "ruben@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Build calendars in the iCalendar format",
"homepage": "https://github.com/spatie/icalendar-generator",
"keywords": [
"calendar",
"iCalendar",
"ical",
"ics",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/icalendar-generator/issues",
"source": "https://github.com/spatie/icalendar-generator/tree/3.0.0"
},
"time": "2025-04-17T14:50:03+00:00"
},
{
"name": "spomky-labs/pki-framework",
"version": "1.3.0",
@@ -7724,6 +7783,79 @@
],
"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",
"version": "v7.3.0",

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ services:
app:
image: registry.caldwell.digital/home/torsearch-app:${TAG}
ports:
- '8001:80'
- "${SWARM_PORT}:80"
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
@@ -15,10 +15,6 @@ services:
- mercure_config:/config
depends_on:
- database
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12202"
worker:
@@ -32,10 +28,6 @@ services:
replicas: 2
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12203"
scheduler:
@@ -47,11 +39,6 @@ services:
command: -vv
depends_on:
- app
logging:
driver: "gelf"
options:
gelf-address: "tcp://192.168.1.197:12204"
redis:

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,8 @@
FROM dunglas/frankenphp:php8.4-alpine
FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ARG APP_VERSION="0.0.0-dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apk add --no-cache wget
COPY . /app
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]

View File

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

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 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 sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE 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 download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
SQL);
}
}

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;
use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
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 = [];
public function __construct(
private readonly DenormalizerInterface $denormalizer,
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.version')]
private readonly ?string $appVersion = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
@@ -92,6 +101,13 @@ final class ConfigResolver
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
{
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 TvShow = 'tvshows';
case TvEpisode = 'tvepisode';
}

View File

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

View File

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

View File

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

View File

@@ -3,13 +3,14 @@
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
@@ -27,7 +28,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
public function __construct(
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private Tmdb $tmdb,
private TmdbClient $tmdb,
private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
@@ -36,7 +37,8 @@ readonly class DownloadSeasonHandler implements HandlerInterface
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);
$episodesInSeason = Map::from($series->episodes[$command->season]);

View File

@@ -5,12 +5,11 @@ namespace App\Monitor\Action\Handler;
use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
@@ -33,7 +32,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
private Tmdb $tmdb,
private TmdbClient $tmdb,
private DownloadRepository $downloadRepository,
) {}
@@ -43,8 +42,24 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
$episodeData = $this->tmdb->tvEpisodeDetails($monitor->getTmdbId(), $monitor->getImdbId(), $monitor->getSeason(), $monitor->getEpisode());
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 (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');
return new MonitorTvEpisodeResult(
status: 'OK',

View File

@@ -9,7 +9,7 @@ use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Result\MonitorTvSeasonResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN;
@@ -26,7 +26,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private Tmdb $tmdb,
private TmdbClient $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {}
@@ -50,7 +50,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
// Compare against list from TMDB
$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']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());

View File

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

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

View File

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

View File

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

View File

@@ -56,6 +56,9 @@ class Monitor
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTime $airDate = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
@@ -65,7 +68,7 @@ class Monitor
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
private ?self $parent = null;
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')]
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent', cascade: ['remove'])]
private Collection $children;
public function __construct()
@@ -257,6 +260,17 @@ class Monitor
return $this;
}
public function getAirDate(): ?\DateTimeInterface
{
return $this->airDate;
}
public function setAirDate(?\DateTimeInterface $airDate): static
{
$this->airDate = $airDate;
return $this;
}
public function removeChild(self $child): static
{
if ($this->children->removeElement($child)) {

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Search\Action\Result;
use Aimeos\Map;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<SearchResult> */
@@ -9,6 +10,6 @@ class SearchResult implements ResultInterface
{
public function __construct(
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\SearchInput;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Tmdb\TmdbResult;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;

View File

@@ -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;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
#[Route('/api/tmdb/ajax-search', name: 'api_tmdb_ajax_search', methods: ['GET'])]
public function test(Tmdb $tmdb, Request $request): Response
public function test(TmdbClient $tmdb, Request $request): Response
{
$results = [];
@@ -22,7 +22,7 @@ class ApiController extends AbstractController
if (null !== $term) {
if (ImdbMatcher::isMatch($term)) {
$tmdbResult = $tmdb->findByImdbId($term);
$tmdbResult = $tmdb->search($term);
$results = [
[
'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,408 +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
{
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['first_air_date']))->format('Y'),
mediaType: "tvshows",
episodes: $data['episodes'],
);
}
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
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: (new \DateTime($data['air_date']))->format('m/d/Y'),
);
}
private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
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: (new \DateTime($data['release_date']))->format('m/d/Y'),
);
}
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;
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
{
/**
* @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(
#[SerializedPath('[external_ids][imdb_id]')]
public ?string $imdbId = "",
public ?string $tmdbId = "",
#[SerializedPath('[id]')]
public ?int $tmdbId = null,
public ?string $title = "",
public ?string $poster = "",
#[SerializedPath('[overview]')]
public ?string $description = "",
public ?string $year = "",
public ?string $poster = "",
public ?\DateTimeInterface $premiereDate = null,
public ?string $year = null,
public ?string $mediaType = "",
#[Context(denormalizationContext: [
'media_type' => MediaType::TvEpisode->value
])]
#[SerializedPath('[seasons]')]
public ?array $episodes = 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;
use App\Base\Service\MediaFiles;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Result\GetMovieOptionsResult;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
@@ -13,14 +13,14 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetMovieOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly TmdbClient $tmdb,
private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->imdbId, 'movies');
$media = $this->tmdb->movieDetails($command->imdbId);
return new GetMovieOptionsResult(
media: $media,
file: $this->mediaFiles->movieExists($media->title),

View File

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

View File

@@ -3,14 +3,15 @@
namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $parentShow,
public TmdbResult $media,
public TmdbResult|TmdbEpisodeDto $parentShow,
public TmdbResult|TmdbEpisodeDto $media,
public MediaFileDto|false $file,
public string $season,
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;
use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
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
{
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(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
) {
$this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
$this->client = new Client([
'base_uri' => $this->searchUrl,
]);
}
private readonly HttpClient $client,
) {}
public function search(string $imdbCode, string $type, bool $parseResults = true): array
{
$cacheKey = "torrentio.{$imdbCode}";
$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 [];
});
$cacheTags = ['torrentio', $type, $imdbCode];
$results = $this->client->get($imdbCode, $cacheTags);
if (true === $parseResults) {
return $this->parse($results);
return $this->parse($results, $imdbCode);
}
return $results;
@@ -65,39 +25,21 @@ class Torrentio
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array
{
$cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->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 [];
});
$cacheTags = ['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"];
$results = $this->client->get("$imdbId:$season:$episode", $cacheTags);
if (null === $results) {
throw new TorrentioRateLimitException();
}
if (true === $parseResults) {
return $this->parse($results);
return $this->parse($results, $imdbId);
}
return $results;
}
public function parse(array $data): array
public function parse(array $data, string $imdbId): array
{
$results = [];
foreach ($data['streams'] as $stream) {
@@ -117,7 +59,8 @@ class Torrentio
$result = ResultFactory::map(
$stream['url'],
$stream['title'],
$bingeGroup
$bingeGroup,
$imdbId
);
$results[] = $result;

View File

@@ -18,30 +18,35 @@ class ResultFactory
public static function map(
string $url,
string $title,
string $bingeGroup = "-"
string $bingeGroup = "-",
string $imdbId = "-",
) {
$ptn = (object) (new PTN())->parse($title);
return new TorrentioResult(
$title = trim(preg_replace('/\s+/', ' ', $title));
$ptn = (object) new PTN()->parse(self::setFilename($url));
$result = new TorrentioResult(
self::trimTitle($title),
urldecode($url),
self::setUrl($url),
self::setFilename($url),
self::setSize($title),
self::setSeeders($title),
self::setProvider($title),
self::setEpisode($title),
$ptn->season ?? "-",
self::setSeason($ptn),
$bingeGroup,
$ptn->resolution ?? "-",
self::setCodec($ptn->codec ?? "-"),
$ptn->quality ?? "-",
self::setResolution($ptn),
self::setCodec($ptn),
self::setQuality($ptn),
$ptn,
substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-",
self::setKey($url),
self::setEpisodeNumber($ptn),
self::setLanguages($title),
self::setLanguageFlags($title),
false,
uniqid()
uniqid(),
$imdbId,
);
return $result;
}
public static function setFilename(string $url)
@@ -50,6 +55,11 @@ class ResultFactory
return end($file);
}
public static function setUrl(string $url): string
{
return urldecode($url);
}
public static function setSize(string $title): string
{
$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)
@@ -122,6 +138,36 @@ class ResultFactory
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)
{
$emoji = \Emoji\detect_emoji($title);

View File

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

View File

@@ -49,7 +49,6 @@ final class TvEpisodeList
}
$this->reloadCount++;
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;
use App\Base\ConfigResolver;
use App\Base\Dto\AppVersionDto;
use App\Base\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
@@ -13,9 +16,16 @@ class UtilExtension
{
public function __construct(
private readonly ConfigResolver $config,
private readonly MediaFiles $mediaFiles,
) {}
#[AsTwigFunction('app_version')]
public function app_version(): AppVersionDto
{
return $this->config->getAppVersion();
}
#[AsTwigFunction('uniqid')]
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\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Handler\SaveUserCalendarPreferencesHandler;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserCalendarPreferencesInput;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Database\CountryLanguages;
@@ -33,15 +35,15 @@ class PreferencesController extends AbstractController
public function mediaPreferences(): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
// dd($form);
return $this->render(
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -54,8 +56,8 @@ class PreferencesController extends AbstractController
): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$form = $this->createForm(UserMediaPreferencesForm::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -69,6 +71,7 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
@@ -81,6 +84,7 @@ class PreferencesController extends AbstractController
): Response
{
$downloadPreferences = $this->getUser()->getDownloadPreferences();
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
@@ -95,6 +99,34 @@ class PreferencesController extends AbstractController
'user/preferences.html.twig',
[
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);
}
#[Route('/user/preferences/calendar', 'app.save.calendar-preferences', methods: ['POST'])]
public function saveCalendarPreferences(
SaveUserCalendarPreferencesInput $input,
SaveUserCalendarPreferencesHandler $handler,
): Response
{
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$handler->handle($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: 'Your calendar preferences have been saved.'
);
return $this->render(
'user/preferences.html.twig',
[
'downloadPreferences' => $this->getUser()->getDownloadPreferences(),
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
]
);

View File

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

View File

@@ -20,7 +20,7 @@
{% block body %}{% endblock %}
<div class="mt-2 inline-flex gap-4 justify-between text-white">
<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>
</body>

View File

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

View File

@@ -39,15 +39,9 @@
</div>
{{ form_end(preferences_form) }}
<div class="flex flex-col 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>
<div class="flex flex-col-reverse md:flex-row justify-between">
{% 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">
<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

View File

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

View File

@@ -47,7 +47,7 @@
{% endfor %}
{% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all">
<td colspan="100%" class="py-2 whitespace-nowrap bg-gray-400 dark:bg-gray-700 uppercase text-xs font-medium text-center text-black dark:text-white min-w-[50ch] max-w-[50ch] truncate">
<td colspan="100%" class="py-2 whitespace-nowrap bg-orange-500/80 uppercase text-xs font-medium text-center truncate dark:text-black">
<a href="{{ path('app_monitors') }}">View All Monitors</a>
</td>
</tr>

View File

@@ -14,18 +14,10 @@
>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
class="mr-1 hover:underline rounded-md"
>
{% if monitor.monitorType == "movies" %}
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType} %}
{% set route = path('app_search_result', routeParams) %}
{% else %}
{% set episodeIdDto = extract_from_episode_id(monitor|monitor_media_id) %}
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
{% endif %}
<a href="{{ route }}"
class="mr-1 hover:underline rounded-md">
{% set episodeIdDto = extract_from_episode_id(monitor|monitor_media_id) %}
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type, season: episodeIdDto.season} %}
<a href="{{ path('app_search_result', routeParams) }}"
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate dark:text-white">
{{ monitor.title }}
</a>

View File

@@ -55,7 +55,7 @@
</div>
</a>
<p class="px-4 pt-1 inline-flex justify-center">
<small class="text-white text-xs">v{{ version|default('0.0') }}</small>
<small class="text-white text-xs">{{ app_version() }}</small>
</p>
</div>
</nav>

View File

@@ -2,24 +2,24 @@
class="episode-list flex flex-col gap-4"
>
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
{% if this.getEpisodes().items != null %}
{% for episode in this.getEpisodes().items %}
<episode-container id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
<episode-container id="{{ episode_anchor(episode.seasonNumber, episode.episodeNumber) }}" class="results"
show-title="{{ this.title }}"
data-tv-results-loading-icon-outlet=".loading-icon"
data-download-button-outlet=".download-btn"
{{ stimulus_controller('tv_results', {
title: this.title,
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
season: episode.seasonNumber,
episode: episode.episodeNumber,
active: 'true',
}) }}
>
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500/60 bg-clip-padding backdrop-filter backdrop-blur-md rounded-md">
<div class="flex flex-col md:flex-row gap-4">
{% if episode['poster'] != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />
{% if "jpg" in episode.poster %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode.poster }}" />
{% else %}
<div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" />
@@ -27,28 +27,28 @@
{% endif %}
<div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold">
{{ episode['episode_number'] }}. {{ episode['name'] }}
{{ episode.episodeNumber }}. {{ episode.name }}
</h4>
<p>{{ episode['overview']|truncate }}</p>
<div>
<button class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode['season_number'] }} episode {{ episode['episode_number'] }}.">
<span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}>-</span> results
<p>{{ episode.description|truncate }}</p>
<div class="text-xs font-bold">
<button class="results-count-badge py-1 px-1.5 mr-1 grow-0 bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode.seasonNumber }} episode {{ episode.episodeNumber }}.">
<span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}></span> results
</button>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title='"{{ episode['name'] }}" aired on {{ episode['air_date']|date(null, 'UTC') }}.'>
{{ episode['air_date']|date(null, 'UTC') }}
</small>
<span class="py-1 px-1.5 mr-1 grow-0 bg-gray-700 rounded-lg text-white" title='"{{ episode.name }}" aired on {{ episode.airDate|date(null, 'UTC') }}.'>
{{ episode.airDate|date(null, 'UTC') }}
</span>
<twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', {
<twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode.seasonNumber, episode.episodeNumber) }}" src="{{ path('api.library.search', {
title: this.title,
season: episode['season_number'],
episode: episode['episode_number'],
season: episode.seasonNumber,
episode: episode.episodeNumber,
block: 'media_exists_badge',
target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number'])
target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode.seasonNumber, episode.episodeNumber)
}) }}">
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
<span class="py-1 px-1.5 mr-1 grow-0 bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
missing
</small>
</span>
</twig:Turbo:Frame>
</div>
</div>
@@ -58,7 +58,7 @@
{{ stimulus_target('tv-results', 'episodeSelector') }}
/>
</div>
<button class="dropdown-button flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90" title="Click to expand the results table for season {{ episode['season_number'] }} episode {{ episode['episode_number'] }}.">
<button class="dropdown-button flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90" title="Click to expand the results table for season {{ episode.seasonNumber }} episode {{ episode.episodeNumber }}.">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32">
<path
fill="none"
@@ -72,18 +72,21 @@
</div>
</div>
<div class="results-container inline-block overflow-hidden rounded-lg hidden">
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', {
<twig:Turbo:Frame loading="lazy" id="results_{{ episode_id(episode.seasonNumber, episode.episodeNumber) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']),
season: episode.seasonNumber,
episode: episode.episodeNumber,
target: 'results_' ~ episode_id(episode.seasonNumber, episode.episodeNumber),
block: 'tvshow_results'
}) }}" />
}) }}">
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
</twig:Turbo:Frame>
</div>
</div>
</episode-container>
{% endfor %}
{% endif %}
</div>
{% set paginator = this.episodes %}
{% include 'partial/tv-episode-list-paginator.html.twig' %}

View File

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

View File

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

View File

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

View File

@@ -12,20 +12,20 @@
</ul>
</div>
</template>
<small
<span
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
</span>
{% endif %}
{% if result.exists == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white"
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white"
title="Media has not been downloaded yet.">
missing
</small>
</span>
{% endif %}
</template>
</turbo-stream>

View File

@@ -2,16 +2,17 @@
{% block title %}{{ results.media.title }} &mdash; Download Options &mdash; Torsearch{% endblock %}
{% block h2 %}Media Results{% endblock %}
{% block body %}
<div class="p-4 flex flex-col grow gap-4">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2>
<div class="flex flex-row w-full gap-2">
<twig:Card title="" class="w-full" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
{% if results.media.poster != null %}
<img class="w-full md:w-40 rounded-lg" src="{{ results.media.poster }}" />
<img class="w-full md:w-[12.5rem] rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-full md:w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<div class="w-full md:w-[12.5rem] h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
@@ -27,6 +28,7 @@
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
title: results.media.title,
season: results.media.latestSeason,
})}}
data-monitor-button-result-filter-outlet="#filter"
data-monitor-button-dialog-outlet=".monitor-modal"
@@ -50,29 +52,70 @@
</div>
<p class="text-gray-50">
<p class="text-gray-50 mb-4">
{{ results.media.description }}
</p>
<div>
{% if results.media.stars != null %}
<strong>Starring</strong>: {{ results.media.stars|join(', ') }} <br />
{% endif %}
{% if results.media.directors != null %}
<strong>Directors</strong>: {{ results.media.directors|join(', ') }} <br />
{% endif %}
{% if results.media.producers != null %}
<strong>Producers</strong>: {{ results.media.producers|join(', ') }} <br />
{% endif %}
{% if results.media.creators != null %}
<strong>Creators</strong>: {{ results.media.creators|join(', ') }} <br />
{% endif %}
{% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
{% endfor %}
</div>
{% endif %}
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg text-white">
<span>{{ results.media.numberSeasons }}</span> season(s)
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title='"{{ results.media.title }}" first aired on {{ results.media.premiereDate|date(null, 'UTC') }}.'>
{{ results.media.premiereDate|date(null, 'UTC') }}
</span>
</div>
{% endif %}
{% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow">
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results
</span>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate|date(null, 'UTC') }}
</small>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title,
block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId
}) }}">
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing
</small>
</span>
</twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.premiereDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div>
{% endif %}
</div>
@@ -83,24 +126,46 @@
{% if "movies" == results.media.mediaType %}
<movie-container class="results"
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon"
>
<twig:Turbo:Frame id="movie_results_frame" src="{{ path('app_torrentio_movies', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
target: 'movie_results_frame',
block: 'movie_results'
}) }}" />
}) }}">
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" title="Loading download options for {{ results.media.title }}" />
</twig:Turbo:Frame>
</movie-container>
{% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList
results="results"
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :episodeNumber="results.episode"
loading="defer"
:imdbId="results.media.imdbId"
:season="results.season"
:perPage="20"
:pageNumber="1"
:tmdbId="results.media.tmdbId"
:title="results.media.title"
:episodeNumber="results.episode"
/>
{% endif %}
</twig:Card>
</div>
<twig:Card title="Related Media" contentClass="flex flex-col gap-4 text-white">
<div class="grid grid-cols-2 gap-4 md:flex flex-col md:flex-row justify-between w-full">
{% for media in results.relatedMedia %}
<twig:Poster imdbId="{{ media.imdbId }}"
tmdbId="{{ media.tmdbId }}"
title="{{ media.title }}"
description="{{ media.description }}"
image="{{ media.poster }}"
year="{{ media.year }}"
mediaType="{{ media.mediaType }}"
/>
{% endfor %}
</div>
</twig:Card>
</div>
<style>
html,

View File

@@ -52,7 +52,7 @@
provider="{{ result.provider }}"
languages="{{ result.languages|json_encode }}"
media-type="{{ results.media.mediaType }}"
imdb-id="{{ results.media.imdbId }}"
imdb-id="{{ result.imdbId }}"
filename="{{ result.filename }}"
data-local-id="{{ result.localId }}"
{% if "tvshows" == results.media.mediaType %}

View File

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

View File

@@ -9,7 +9,7 @@ use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -22,7 +22,7 @@ class MonitorTvShowHandlerTest extends TestCase
private MonitorTvEpisodeHandler $episodeHandler;
private MediaFiles $mediaFiles;
private LoggerInterface $logger;
private Tmdb $tmdb;
private TmdbClient $tmdb;
protected function setUp(): void
{
@@ -31,7 +31,7 @@ class MonitorTvShowHandlerTest extends TestCase
$this->episodeHandler = $this->createMock(MonitorTvEpisodeHandler::class);
$this->mediaFiles = $this->createMock(MediaFiles::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->tmdb = $this->createMock(Tmdb::class);
$this->tmdb = $this->createMock(TmdbClient::class);
$this->handler = new MonitorTvShowHandler(
$this->monitorRepository,