Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01840b111 | ||
|
|
1759b6dfdc | ||
|
|
2d3bc35e45 | ||
|
|
69f07b57ce | ||
|
|
b86028acee | ||
|
|
b67781fe23 | ||
|
|
6920b82684 | ||
|
|
0f291aa147 | ||
|
|
c4160081a1 | ||
|
|
5d414590cb | ||
|
|
d28b743684 | ||
|
|
c4e8e9b35e | ||
|
|
6fbd56c952 | ||
|
|
f5732fbcea | ||
|
|
0f095ab7f8 | ||
|
|
2e376337fa | ||
|
|
fc203f1bd3 | ||
|
|
2eda8e0808 | ||
| d3431b76e2 | |||
| a978469564 | |||
| 3097189c49 | |||
| 6f11de70e0 | |||
| 4e06fe6636 | |||
| fd46abf58f | |||
| 2237a45d6f | |||
| 846de2c257 | |||
| d01b725435 | |||
| 7562597629 | |||
| deb0333635 | |||
| c8e190f9e8 | |||
| 538fde40fe | |||
| da7a267e2a | |||
| daf9b2c18b | |||
| d9e5e62f5d | |||
| d4fc7693e3 | |||
| 1263ad20a6 | |||
| e8764bb13b | |||
| a267bab86e | |||
| 9653189bff | |||
| 36836c4d36 | |||
| 61e4b25212 | |||
| 209266597e | |||
| ca89eff236 | |||
| 53da7a746b | |||
| 981699bc13 | |||
| 52f460ff62 | |||
| a42e0d4d1a | |||
| 3e4a2d9bb1 | |||
| af8a30826c | |||
| 09e1c75826 | |||
| f1b8b34359 | |||
| 6f9db68664 | |||
| aeb706b5af | |||
| 7918c260e5 | |||
| 38130ea0ec | |||
| da403958dc | |||
| c2bafabb20 | |||
| e6983aedf9 | |||
| e9edd6a35a | |||
| ee076518b3 | |||
| a2f16398be | |||
| 0f03199eb4 | |||
| d63d477ed1 | |||
| 458229c7ed | |||
| 6748188256 | |||
| b42924048f | |||
| c0f1473037 | |||
| fc797a3a0f | |||
| b8b71fa5b3 | |||
| 662e2600f6 | |||
| aa042e8275 | |||
| 57498b1abf | |||
| fed1e1e122 | |||
| 9eef567974 | |||
| 070723581a | |||
| f3a5c2012e | |||
| 5581a82554 | |||
| 3703272f59 |
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
|
||||
15
Dockerfile
15
Dockerfile
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,10 @@ dialog[data-dialog-target="dialog"][closing] {
|
||||
@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
14
bash/build_base.sh
Executable 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
|
||||
|
||||
17
compose.yml
17
compose.yml
@@ -33,7 +33,12 @@ services:
|
||||
|
||||
|
||||
worker:
|
||||
build: .
|
||||
# build:
|
||||
# dockerfile: docker/Dockerfile.base.worker
|
||||
# context: .
|
||||
# args:
|
||||
# FRANKENPHP_TAG: php8.4-alpine
|
||||
image: torsearch-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- $PWD:/app
|
||||
@@ -41,11 +46,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
|
||||
@@ -88,6 +97,8 @@ services:
|
||||
image: adminer
|
||||
ports:
|
||||
- "8081:8080"
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: database
|
||||
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"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",
|
||||
@@ -46,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",
|
||||
|
||||
79
composer.lock
generated
79
composer.lock
generated
@@ -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": "9ffd10f98137e8975de2c04ac2412ed5",
|
||||
"content-hash": "c133ccd27ac6a41256bdc69129c16546",
|
||||
"packages": [
|
||||
{
|
||||
"name": "1tomany/rich-bundle",
|
||||
@@ -7783,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",
|
||||
@@ -13325,7 +13398,7 @@
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": {},
|
||||
"stability-flags": [],
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
@@ -13333,7 +13406,7 @@
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"platform-dev": [],
|
||||
"platform-overrides": {
|
||||
"php": "8.4"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,13 @@ doctrine:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Monitor/Framework/Entity'
|
||||
prefix: 'App\Monitor\Framework\Entity'
|
||||
alias: Download
|
||||
alias: Monitor
|
||||
EventLog:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/EventLog/Framework/Entity'
|
||||
prefix: 'App\EventLog\Framework\Entity'
|
||||
alias: EventLog
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -15,6 +15,26 @@ framework:
|
||||
max_retries: 1
|
||||
multiplier: 1
|
||||
|
||||
download:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
use_notify: true
|
||||
check_delayed_interval: 60000
|
||||
queue_name: download
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
multiplier: 1
|
||||
|
||||
monitor:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
use_notify: true
|
||||
check_delayed_interval: 60000
|
||||
queue_name: monitor
|
||||
retry_strategy:
|
||||
max_retries: 1
|
||||
multiplier: 1
|
||||
|
||||
media_cache:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
@@ -36,12 +56,12 @@ framework:
|
||||
routing:
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
'App\Download\Action\Command\DownloadMediaCommand': async
|
||||
'App\Download\Action\Command\DownloadMediaCommand': download
|
||||
'App\Download\Action\Command\DownloadSeasonCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvShowCommand': async
|
||||
'App\Monitor\Action\Command\MonitorMovieCommand': async
|
||||
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': monitor
|
||||
'App\Monitor\Action\Command\MonitorTvSeasonCommand': monitor
|
||||
'App\Monitor\Action\Command\MonitorTvShowCommand': monitor
|
||||
'App\Monitor\Action\Command\MonitorMovieCommand': monitor
|
||||
'App\Torrentio\Action\Command\GetTvShowOptionsCommand': media_cache
|
||||
|
||||
# when@test:
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -6,6 +6,14 @@ controllersBase:
|
||||
defaults:
|
||||
schemes: [ 'https' ]
|
||||
|
||||
controllersEventLog:
|
||||
resource:
|
||||
path: ../src/EventLog/Framework/Controller/
|
||||
namespace: App\EventLog\Framework\Controller
|
||||
type: attribute
|
||||
defaults:
|
||||
schemes: [ 'https' ]
|
||||
|
||||
controllersLibrary:
|
||||
resource:
|
||||
path: ../src/Library/Framework/Controller/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -27,31 +23,22 @@ services:
|
||||
- /mnt/media/downloads/movies:/var/download/movies
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
restart: always
|
||||
command: -vv --time-limit=3600 --limit=10
|
||||
# command: -vv --time-limit=3600 --limit=10
|
||||
deploy:
|
||||
replicas: 2
|
||||
depends_on:
|
||||
- app
|
||||
logging:
|
||||
driver: "gelf"
|
||||
options:
|
||||
gelf-address: "tcp://192.168.1.197:12203"
|
||||
|
||||
|
||||
scheduler:
|
||||
image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
|
||||
volumes:
|
||||
- /mnt/media/downloads/movies:/var/download/movies
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
restart: always
|
||||
command: -vv
|
||||
depends_on:
|
||||
- app
|
||||
logging:
|
||||
driver: "gelf"
|
||||
options:
|
||||
gelf-address: "tcp://192.168.1.197:12204"
|
||||
|
||||
#
|
||||
# scheduler:
|
||||
# image: registry.caldwell.digital/home/torsearch-scheduler:${TAG}
|
||||
# volumes:
|
||||
# - /mnt/media/downloads/movies:/var/download/movies
|
||||
# - /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
# restart: always
|
||||
# command: -vv
|
||||
# depends_on:
|
||||
# - app
|
||||
|
||||
|
||||
redis:
|
||||
|
||||
@@ -10,6 +10,7 @@ templates
|
||||
var
|
||||
vendor
|
||||
build.xml
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
composer.json
|
||||
|
||||
@@ -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
|
||||
|
||||
14
docker/Dockerfile.base.app
Normal file
14
docker/Dockerfile.base.app
Normal 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
|
||||
19
docker/Dockerfile.base.worker
Normal file
19
docker/Dockerfile.base.worker
Normal 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
|
||||
@@ -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" ]
|
||||
|
||||
@@ -1,23 +1,40 @@
|
||||
FROM dunglas/frankenphp:php8.4-alpine
|
||||
###
|
||||
# This version of Torsearch runs the scheduler, downloader, and worker
|
||||
# in this one container. Each process is managerd by supervisord
|
||||
# and can be configured via environment variables and more
|
||||
# than one of these containers cans till be run.
|
||||
###
|
||||
ARG APP_VERSION="latest"
|
||||
|
||||
ENV SERVER_NAME=":80"
|
||||
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
|
||||
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS base_image
|
||||
|
||||
ARG APP_VERSION="0.dev"
|
||||
ENV APP_VERSION="${APP_VERSION}"
|
||||
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
gd \
|
||||
intl \
|
||||
zip \
|
||||
opcache
|
||||
FROM alpine:3.22 AS build_stage
|
||||
|
||||
RUN apk add --no-cache wget
|
||||
COPY --from=base_image --chown=1000:1000 /app /app
|
||||
|
||||
COPY . /app
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
nginx \
|
||||
php84 \
|
||||
php84-ctype \
|
||||
php84-curl \
|
||||
php84-dom \
|
||||
php84-fileinfo \
|
||||
php84-fpm \
|
||||
php84-gd \
|
||||
php84-mbstring \
|
||||
php84-mysqli \
|
||||
php84-opcache \
|
||||
php84-openssl \
|
||||
php84-pdo_mysql \
|
||||
php84-tokenizer \
|
||||
supervisor
|
||||
|
||||
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]
|
||||
RUN ln -s /usr/bin/php84 /usr/bin/php
|
||||
|
||||
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0
|
||||
COPY docker/worker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
# We start supervisord and supervisord starts
|
||||
# respective service in the configuration file.
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
|
||||
38
docker/worker/supervisord.conf
Normal file
38
docker/worker/supervisord.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/run/supervisord.pid
|
||||
|
||||
[program:torsearch-worker]
|
||||
command=/app/bin/console messenger:consume async -vv --time-limit 3600
|
||||
numprocs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=3
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
|
||||
[program:torsearch-worker]
|
||||
command=/app/bin/console messenger:consume download -vv --time-limit 3600
|
||||
numprocs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=3
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
|
||||
[program:torsearch-scheduler]
|
||||
command=/app/bin/console messenger:consume scheduler_monitor monitor -vv --time-limit 21600
|
||||
numprocs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=3
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
41
migrations/Version20250831013403.php
Normal file
41
migrations/Version20250831013403.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250831013403 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE IF EXISTS sessions
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
35
migrations/Version20251101194723.php
Normal file
35
migrations/Version20251101194723.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251101194723 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
35
migrations/Version20251101211617.php
Normal file
35
migrations/Version20251101211617.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251101211617 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE event_log (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) DEFAULT NULL, message LONGTEXT DEFAULT NULL, context JSON DEFAULT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE event_log
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
47
migrations/Version20251102004627.php
Normal file
47
migrations/Version20251102004627.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251102004627 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE event_log ADD user_id INT DEFAULT NULL
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE event_log ADD CONSTRAINT FK_9EF0AD16A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE INDEX IDX_9EF0AD16A76ED395 ON event_log (user_id)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE event_log DROP FOREIGN KEY FK_9EF0AD16A76ED395
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP INDEX IDX_9EF0AD16A76ED395 ON event_log
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE event_log DROP user_id
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
35
migrations/Version20251102221034.php
Normal file
35
migrations/Version20251102221034.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20251102221034 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE event_log ADD created_at DATETIME NULL, ADD updated_at DATETIME NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE event_log DROP created_at, DROP updated_at
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
17
psalm.xml
Normal file
17
psalm.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0"?>
|
||||
<psalm
|
||||
errorLevel="7"
|
||||
resolveFromConfigFile="true"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="https://getpsalm.org/schema/config"
|
||||
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
|
||||
findUnusedBaselineEntry="true"
|
||||
findUnusedCode="true"
|
||||
>
|
||||
<projectFiles>
|
||||
<directory name="src" />
|
||||
<ignoreFiles>
|
||||
<directory name="vendor" />
|
||||
</ignoreFiles>
|
||||
</projectFiles>
|
||||
</psalm>
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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 [
|
||||
|
||||
24
src/Base/Dto/AppVersionDto.php
Normal file
24
src/Base/Dto/AppVersionDto.php
Normal 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 : '');
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,5 @@ enum MediaType: string
|
||||
{
|
||||
case Movie = 'movies';
|
||||
case TvShow = 'tvshows';
|
||||
case TvEpisode = 'tvepisode';
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +48,9 @@ final class IndexController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/test')]
|
||||
public function monitorTvShow(): Response
|
||||
public function monitorTvShow(MonitorTvShowHandler $handler): Response
|
||||
{
|
||||
// $handler->handle(new MonitorTvShowCommand(82));
|
||||
return $this->render('index/test.html.twig', []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,22 @@ namespace App\Download\Action\Handler;
|
||||
|
||||
use App\Download\Action\Command\DeleteDownloadCommand;
|
||||
use App\Download\Action\Result\DeleteDownloadResult;
|
||||
use App\Download\DownloadEvents;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use App\Library\Action\Command\DeleteMediaFileCommand;
|
||||
use App\Library\Action\Handler\DeleteMediaFileHandler;
|
||||
use App\Monitor\MonitorEvents;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/** @implements HandlerInterface<DeleteDownloadCommand, DeleteDownloadResult> */
|
||||
readonly class DeleteDownloadHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private MessageBusInterface $bus,
|
||||
private DownloadRepository $downloadRepository,
|
||||
private DeleteMediaFileHandler $deleteMediaFileHandler,
|
||||
) {}
|
||||
@@ -31,6 +36,13 @@ readonly class DeleteDownloadHandler implements HandlerInterface
|
||||
}
|
||||
$this->downloadRepository->delete($command->downloadId);
|
||||
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$download->getUser(),
|
||||
DownloadEvents::DOWNLOAD_DELETED->type(),
|
||||
DownloadEvents::DOWNLOAD_DELETED->message(),
|
||||
(array) $download
|
||||
));
|
||||
|
||||
return new DeleteDownloadResult(
|
||||
status: 200,
|
||||
message: 'Success',
|
||||
|
||||
@@ -4,18 +4,22 @@ namespace App\Download\Action\Handler;
|
||||
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\Action\Result\DownloadMediaResult;
|
||||
use App\Download\DownloadEvents;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Download\Downloader\DownloaderInterface;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
|
||||
readonly class DownloadMediaHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private MessageBusInterface $bus,
|
||||
private DownloaderInterface $downloader,
|
||||
private DownloadRepository $downloadRepository,
|
||||
private UserRepository $userRepository,
|
||||
@@ -23,9 +27,17 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$user = $this->userRepository->find($command->userId);
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$user,
|
||||
DownloadEvents::DOWNLOAD_STARTED->type(),
|
||||
DownloadEvents::DOWNLOAD_STARTED->message(),
|
||||
(array) $command
|
||||
));
|
||||
|
||||
if (null === $command->downloadId) {
|
||||
$download = $this->downloadRepository->insert(
|
||||
$this->userRepository->find($command->userId),
|
||||
$user,
|
||||
$command->url,
|
||||
$command->title,
|
||||
$command->filename,
|
||||
@@ -57,6 +69,12 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
||||
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
|
||||
}
|
||||
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$user,
|
||||
DownloadEvents::DOWNLOAD_FINISHED->type(),
|
||||
DownloadEvents::DOWNLOAD_FINISHED->message(),
|
||||
(array) $command
|
||||
));
|
||||
return new DownloadMediaResult(200, "Success.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
34
src/Download/DownloadEvents.php
Normal file
34
src/Download/DownloadEvents.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download;
|
||||
|
||||
enum DownloadEvents
|
||||
{
|
||||
case DOWNLOAD_ADDED;
|
||||
case DOWNLOAD_STARTED;
|
||||
case DOWNLOAD_FINISHED;
|
||||
case DOWNLOAD_DELETED;
|
||||
case DOWNLOAD_ERROR;
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DOWNLOAD_ADDED => 'download_added',
|
||||
self::DOWNLOAD_STARTED => 'download_started',
|
||||
self::DOWNLOAD_FINISHED => 'download_finished',
|
||||
self::DOWNLOAD_DELETED => 'download_deleted',
|
||||
self::DOWNLOAD_ERROR => 'download_error',
|
||||
};
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DOWNLOAD_ADDED => 'A new download has been added.',
|
||||
self::DOWNLOAD_STARTED => 'A download has started.',
|
||||
self::DOWNLOAD_FINISHED => 'A download has finished.',
|
||||
self::DOWNLOAD_DELETED => 'A download has been deleted.',
|
||||
self::DOWNLOAD_ERROR => 'A download has encountered an error.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ namespace App\Download\Downloader;
|
||||
|
||||
use App\Base\Service\Broadcaster;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Download\DownloadEvents;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
@@ -17,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
|
||||
* @var RedisAdapter $cache
|
||||
*/
|
||||
public function __construct(
|
||||
private MessageBusInterface $bus,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MediaFiles $mediaFiles,
|
||||
private CacheInterface $cache,
|
||||
@@ -88,6 +92,12 @@ class ProcessDownloader implements DownloaderInterface
|
||||
}
|
||||
} catch (ProcessFailedException $exception) {
|
||||
$downloadEntity->setStatus('Failed');
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$downloadEntity->getUser()->getId(),
|
||||
DownloadEvents::DOWNLOAD_ERROR->type(),
|
||||
DownloadEvents::DOWNLOAD_ERROR->message() . ': ' . $exception->getMessage(),
|
||||
(array) $downloadEntity
|
||||
));
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -11,7 +11,9 @@ use App\Download\Action\Input\DownloadMediaInput;
|
||||
use App\Download\Action\Input\DownloadSeasonInput;
|
||||
use App\Download\Action\Input\PauseDownloadInput;
|
||||
use App\Download\Action\Input\ResumeDownloadInput;
|
||||
use App\Download\DownloadEvents;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
@@ -41,6 +43,13 @@ class ApiController extends AbstractController
|
||||
$input->downloadId = $download->getId();
|
||||
$input->userId = $this->getUser()->getId();
|
||||
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$this->getUser(),
|
||||
DownloadEvents::DOWNLOAD_ADDED->type(),
|
||||
DownloadEvents::DOWNLOAD_ADDED->message(),
|
||||
(array) $download
|
||||
));
|
||||
|
||||
try {
|
||||
$this->bus->dispatch($input->toCommand());
|
||||
} catch (\Throwable $exception) {
|
||||
|
||||
17
src/EventLog/Action/Command/AddEventLogCommand.php
Normal file
17
src/EventLog/Action/Command/AddEventLogCommand.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Action\Command;
|
||||
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/** @implements CommandInterface<AddEventLogCommand> */
|
||||
class AddEventLogCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public UserInterface $user,
|
||||
public string $type,
|
||||
public string $message,
|
||||
public array $context,
|
||||
) {}
|
||||
}
|
||||
29
src/EventLog/Action/Handler/AddEventLogHandler.php
Normal file
29
src/EventLog/Action/Handler/AddEventLogHandler.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Action\Handler;
|
||||
|
||||
use App\EventLog\Framework\Repository\EventLogRepository;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use App\EventLog\Action\Result\AddEventLogResult;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
/*** @implements HandlerInterface<AddEventLogCommand> */
|
||||
class AddEventLogHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EventLogRepository $repository,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$eventLog = $this->repository->insert(
|
||||
user: $command->user,
|
||||
type: $command->type,
|
||||
message: $command->message,
|
||||
context: $command->context
|
||||
);
|
||||
return new AddEventLogResult($eventLog);
|
||||
}
|
||||
}
|
||||
36
src/EventLog/Action/Input/AddEventLogInput.php
Normal file
36
src/EventLog/Action/Input/AddEventLogInput.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Action\Input;
|
||||
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use OneToMany\RichBundle\Attribute\SourceQuery;
|
||||
use OneToMany\RichBundle\Attribute\SourceRequest;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\InputInterface;
|
||||
|
||||
/** @implements InputInterface<AddEventLogCommand> */
|
||||
class AddEventLogInput implements InputInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[SourceQuery('type')]
|
||||
#[SourceRequest('type')]
|
||||
public string $type,
|
||||
|
||||
#[SourceQuery('message')]
|
||||
#[SourceRequest('message')]
|
||||
public string $message,
|
||||
|
||||
#[SourceQuery('context')]
|
||||
#[SourceRequest('context')]
|
||||
public array $context = []
|
||||
){}
|
||||
|
||||
public function toCommand(): CommandInterface
|
||||
{
|
||||
return new AddEventLogCommand(
|
||||
$this->type,
|
||||
$this->message,
|
||||
$this->context
|
||||
);
|
||||
}
|
||||
}
|
||||
14
src/EventLog/Action/Result/AddEventLogResult.php
Normal file
14
src/EventLog/Action/Result/AddEventLogResult.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Action\Result;
|
||||
|
||||
use App\EventLog\Framework\Entity\EventLog;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
/** @implements ResultInterface<AddEventLogResult> */
|
||||
class AddEventLogResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public EventLog $eventLog,
|
||||
) {}
|
||||
}
|
||||
28
src/EventLog/Framework/Controller/EventLogController.php
Normal file
28
src/EventLog/Framework/Controller/EventLogController.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Framework\Controller;
|
||||
|
||||
use App\Base\Service\Broadcaster;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class EventLogController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Broadcaster $broadcaster,
|
||||
) {}
|
||||
|
||||
#[Route('/alert', name: 'app_alert')]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->broadcaster->alert(
|
||||
'Added to queue',
|
||||
'This is a testy test!'
|
||||
);
|
||||
|
||||
return $this->json([
|
||||
'Success' => 'Published'
|
||||
]);
|
||||
}
|
||||
}
|
||||
82
src/EventLog/Framework/Entity/EventLog.php
Normal file
82
src/EventLog/Framework/Entity/EventLog.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Framework\Entity;
|
||||
|
||||
use App\EventLog\Framework\Repository\EventLogRepository;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Gedmo\Timestampable\Traits\TimestampableEntity;
|
||||
|
||||
#[ORM\Entity(repositoryClass: EventLogRepository::class)]
|
||||
class EventLog
|
||||
{
|
||||
use TimestampableEntity;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $message = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $context = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'eventLogs')]
|
||||
private ?User $user = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function setMessage(?string $message): self
|
||||
{
|
||||
$this->message = $message;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContext(): ?array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
|
||||
public function setContext(?array $context): self
|
||||
{
|
||||
$this->context = $context;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?User $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
35
src/EventLog/Framework/Repository/EventLogRepository.php
Normal file
35
src/EventLog/Framework/Repository/EventLogRepository.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventLog\Framework\Repository;
|
||||
|
||||
use App\EventLog\Framework\Entity\EventLog;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<EventLog>
|
||||
*/
|
||||
class EventLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, EventLog::class);
|
||||
}
|
||||
|
||||
public function insert(
|
||||
User $user,
|
||||
string $type,
|
||||
string $message,
|
||||
array $context = []
|
||||
): EventLog {
|
||||
$eventLog = new EventLog()
|
||||
->setUser($user)
|
||||
->setType($type)
|
||||
->setMessage($message)
|
||||
->setContext($context);
|
||||
$this->getEntityManager()->persist($eventLog);
|
||||
$this->getEntityManager()->flush();
|
||||
return $eventLog;
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
namespace App\Monitor\Action\Handler;
|
||||
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use App\Monitor\Action\Command\AddMonitorCommand;
|
||||
use App\Monitor\Action\Result\AddMonitorResult;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Monitor\MonitorEvents;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/** @implements HandlerInterface<AddMonitorCommand> */
|
||||
readonly class AddMonitorHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private MessageBusInterface $bus,
|
||||
private MonitorRepository $movieMonitorRepository,
|
||||
private UserRepository $userRepository,
|
||||
) {}
|
||||
@@ -35,6 +39,13 @@ readonly class AddMonitorHandler implements HandlerInterface
|
||||
->setSearchCount(0)
|
||||
->setStatus('New');
|
||||
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$user,
|
||||
MonitorEvents::MONITOR_ADDED->type(),
|
||||
MonitorEvents::MONITOR_ADDED->message(),
|
||||
(array) $monitor
|
||||
));
|
||||
|
||||
$this->movieMonitorRepository->getEntityManager()->persist($monitor);
|
||||
$this->movieMonitorRepository->getEntityManager()->flush();
|
||||
|
||||
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
namespace App\Monitor\Action\Handler;
|
||||
|
||||
use App\Monitor\Action\Command\AddMonitorCommand;
|
||||
use App\Download\DownloadEvents;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use App\Monitor\Action\Command\DeleteMonitorCommand;
|
||||
use App\Monitor\Action\Result\AddMonitorResult;
|
||||
use App\Monitor\Action\Result\DeleteMonitorResult;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use App\Monitor\MonitorEvents;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/** @implements HandlerInterface<DeleteMonitorCommand, DeleteMonitorResult> */
|
||||
readonly class DeleteMonitorHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private MessageBusInterface $bus,
|
||||
private MonitorRepository $monitorRepository,
|
||||
) {}
|
||||
|
||||
@@ -27,6 +27,13 @@ readonly class DeleteMonitorHandler implements HandlerInterface
|
||||
$this->monitorRepository->getEntityManager()->remove($monitor);
|
||||
$this->monitorRepository->getEntityManager()->flush();
|
||||
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$monitor->getUser(),
|
||||
MonitorEvents::MONITOR_DELETED->type(),
|
||||
MonitorEvents::MONITOR_DELETED->message(),
|
||||
(array) $monitor
|
||||
));
|
||||
|
||||
return new DeleteMonitorResult(
|
||||
status: 'OK',
|
||||
result: [],
|
||||
|
||||
@@ -6,10 +6,12 @@ use App\Base\Util\EpisodeId;
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\DownloadOptionEvaluator;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\EventLog\Action\Command\AddEventLogCommand;
|
||||
use App\Monitor\Action\Command\MonitorMovieCommand;
|
||||
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Tmdb\Tmdb;
|
||||
use App\Monitor\MonitorEvents;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
use App\User\Dto\UserPreferencesFactory;
|
||||
@@ -32,7 +34,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
private MessageBusInterface $bus,
|
||||
private LoggerInterface $logger,
|
||||
private MonitorRepository $monitorRepository,
|
||||
private Tmdb $tmdb,
|
||||
private TmdbClient $tmdb,
|
||||
private DownloadRepository $downloadRepository,
|
||||
) {}
|
||||
|
||||
@@ -42,13 +44,31 @@ 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());
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$monitor->getUser(),
|
||||
MonitorEvents::MONITOR_STARTED->type(),
|
||||
MonitorEvents::MONITOR_STARTED->message(),
|
||||
(array) $command
|
||||
));
|
||||
|
||||
if (null === $monitor->getAirDate() && null !== $episodeData->episodeAirDate && "" !== $episodeData->episodeAirDate) {
|
||||
$monitor->setAirDate(Carbon::parse($episodeData->episodeAirDate));
|
||||
$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 (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
|
||||
if (null === $monitor->getAirDate()) {
|
||||
$monitor->setAirDate(Carbon::parse($episodeData->airDate));
|
||||
}
|
||||
|
||||
if (Carbon::createFromTimestamp($episodeData->airDate) > Carbon::today('UTC')) {
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now');
|
||||
return new MonitorTvEpisodeResult(
|
||||
status: 'OK',
|
||||
@@ -105,12 +125,25 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
$this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage());
|
||||
$this->logger->error($exception->getMessage());
|
||||
$monitor->setStatus('Active');
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$monitor->getUser(),
|
||||
MonitorEvents::MONITOR_ERROR->type(),
|
||||
MonitorEvents::MONITOR_ERROR->message() . ': ' . $exception->getMessage(),
|
||||
(array) $monitor
|
||||
));
|
||||
}
|
||||
|
||||
$monitor->setLastSearch(new DateTimeImmutable());
|
||||
$monitor->setSearchCount($monitor->getSearchCount() + 1);
|
||||
$this->monitorRepository->getEntityManager()->flush();
|
||||
|
||||
$this->bus->dispatch(new AddEventLogCommand(
|
||||
$monitor->getUser(),
|
||||
MonitorEvents::MONITOR_FINISHED->type(),
|
||||
MonitorEvents::MONITOR_FINISHED->message(),
|
||||
(array) $monitor
|
||||
));
|
||||
|
||||
return new MonitorTvEpisodeResult(
|
||||
status: 'OK',
|
||||
result: [
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ 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 Carbon\CarbonImmutable;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\Base\Util\PTN;
|
||||
@@ -29,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
|
||||
{
|
||||
@@ -40,78 +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'])
|
||||
->setAirDate($episode['air_date'] !== null && $episode['air_date'] !== "" ? Carbon::parse($episode['air_date']) : null)
|
||||
->setCreatedAt(new DateTimeImmutable())
|
||||
->setSearchCount(0)
|
||||
->setStatus('New');
|
||||
|
||||
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
|
||||
$this->monitorRepository->getEntityManager()->flush();
|
||||
|
||||
$episodeMonitors[] = $episodeMonitor;
|
||||
|
||||
// Immediately run the monitor
|
||||
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
|
||||
$this->monitorTvEpisodeHandler->handle($command);
|
||||
$this->logger->info('> [MonitorTvShowHandler] ...Dispatching MonitorTvEpisodeCommand');
|
||||
// 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.
|
||||
@@ -129,29 +129,31 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
);
|
||||
}
|
||||
|
||||
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, array $episodeInShow): bool
|
||||
{
|
||||
private function episodeReleasedAfterMonitorCreated(
|
||||
string|DateTimeImmutable $monitorStartDate,
|
||||
TmdbEpisodeDto $episodeInShow
|
||||
): bool {
|
||||
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -4,30 +4,38 @@ 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
|
||||
{
|
||||
#[Route('/monitors/ical.ics', name: 'app.monitors.ical')]
|
||||
public function icalAction(MonitorRepository $monitorRepository)
|
||||
#[IsGranted('PUBLIC_ACCESS')]
|
||||
#[Route('/monitors/ical/{email:user}/upcoming-episodes.ics', name: 'app.monitors.ical')]
|
||||
public function icalAction(MonitorRepository $monitorRepository, User $user)
|
||||
{
|
||||
$calendar = new Calendar();
|
||||
if (false === $user->hasICalEnabled()) {
|
||||
return new Response('Calendar not found.', 404);
|
||||
}
|
||||
|
||||
$calendar = Calendar::create()
|
||||
->name('Upcoming Episodes')
|
||||
->refreshInterval(10);
|
||||
|
||||
$monitors = $monitorRepository->whereAirDateNotNull();
|
||||
$events = Map::from($monitors)->map(function ($monitor) {
|
||||
$calendar->event(Map::from($monitors)->map(function ($monitor) {
|
||||
return new Event($monitor->getTitle())
|
||||
->startsAt($monitor->getAirDate())
|
||||
->withoutTimezone()
|
||||
->fullDay()
|
||||
;
|
||||
});
|
||||
$calendar->event($events->toArray());
|
||||
->fullDay();
|
||||
})->toArray());
|
||||
|
||||
return new Response($calendar->get(), 200, [
|
||||
'Content-Type' => 'text/calendar',
|
||||
'Content-Disposition' => 'attachment; filename="upcoming-episodes.ics"',
|
||||
'Content-Disposition' => 'inline; filename="upcoming-episodes.ics"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,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()
|
||||
|
||||
34
src/Monitor/MonitorEvents.php
Normal file
34
src/Monitor/MonitorEvents.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Monitor;
|
||||
|
||||
enum MonitorEvents
|
||||
{
|
||||
case MONITOR_ADDED;
|
||||
case MONITOR_STARTED;
|
||||
case MONITOR_FINISHED;
|
||||
case MONITOR_DELETED;
|
||||
case MONITOR_ERROR;
|
||||
|
||||
public function type(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MONITOR_ADDED => 'monitor_added',
|
||||
self::MONITOR_STARTED => 'monitor_started',
|
||||
self::MONITOR_FINISHED => 'monitor_finished',
|
||||
self::MONITOR_DELETED => 'monitor_deleted',
|
||||
self::MONITOR_ERROR => 'monitor_error',
|
||||
};
|
||||
}
|
||||
|
||||
public function message(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::MONITOR_ADDED => 'A new monitor has been added.',
|
||||
self::MONITOR_STARTED => 'A monitor has started.',
|
||||
self::MONITOR_FINISHED => 'A monitor has finished.',
|
||||
self::MONITOR_DELETED => 'A monitor has been deleted',
|
||||
self::MONITOR_ERROR => 'A monitor has encountered an error.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
src/Tmdb/Dto/CastMemberDto.php
Normal file
19
src/Tmdb/Dto/CastMemberDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/Tmdb/Dto/CrewMemberDto.php
Normal file
18
src/Tmdb/Dto/CrewMemberDto.php
Normal 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
18
src/Tmdb/Dto/GenreDto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Tmdb/Dto/TmdbEpisodeDto.php
Normal file
29
src/Tmdb/Dto/TmdbEpisodeDto.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
105
src/Tmdb/Framework/Serializer/TmdbResultDenormalizer.php
Normal file
105
src/Tmdb/Framework/Serializer/TmdbResultDenormalizer.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tmdb;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Enum\MediaType;
|
||||
use App\ValueObject\ResultFactory;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Tmdb\Api\Find;
|
||||
use Tmdb\Client;
|
||||
use Tmdb\Event\BeforeRequestEvent;
|
||||
use Tmdb\Event\Listener\Psr6CachedRequestListener;
|
||||
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
|
||||
use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
|
||||
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
|
||||
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
|
||||
use Tmdb\Event\RequestEvent;
|
||||
use Tmdb\Model\Movie;
|
||||
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
|
||||
use Tmdb\Model\Tv;
|
||||
use Tmdb\Repository\MovieRepository;
|
||||
use Tmdb\Repository\SearchRepository;
|
||||
use Tmdb\Repository\TvEpisodeRepository;
|
||||
use Tmdb\Repository\TvRepository;
|
||||
use Tmdb\Repository\TvSeasonRepository;
|
||||
use Tmdb\Token\Api\ApiToken;
|
||||
use Tmdb\Token\Api\BearerToken;
|
||||
|
||||
class Tmdb
|
||||
{
|
||||
protected Client $client;
|
||||
|
||||
protected MovieRepository $movieRepository;
|
||||
|
||||
protected TvRepository $tvRepository;
|
||||
|
||||
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
#[Autowire(env: 'TMDB_API')] string $apiKey,
|
||||
) {
|
||||
$this->client = new Client(
|
||||
[
|
||||
/** @var ApiToken|BearerToken */
|
||||
'api_token' => new BearerToken($apiKey),
|
||||
'secure' => true,
|
||||
'base_uri' => Client::TMDB_URI,
|
||||
'event_dispatcher' => [
|
||||
'adapter' => $this->eventDispatcher,
|
||||
],
|
||||
// We make use of PSR-17 and PSR-18 auto discovery to automatically guess these, but preferably set these explicitly.
|
||||
'http' => [
|
||||
'client' => null,
|
||||
'request_factory' => null,
|
||||
'response_factory' => null,
|
||||
'stream_factory' => null,
|
||||
'uri_factory' => null,
|
||||
],
|
||||
'hydration' => [
|
||||
'event_listener_handles_hydration' => false,
|
||||
'only_for_specified_models' => []
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
|
||||
*/
|
||||
$requestListener = new Psr6CachedRequestListener(
|
||||
$this->client->getHttpClient(),
|
||||
$this->eventDispatcher,
|
||||
$cache,
|
||||
$this->client->getHttpClient()->getPsr17StreamFactory(),
|
||||
[]
|
||||
);
|
||||
$this->eventDispatcher->addListener(RequestEvent::class, $requestListener);
|
||||
|
||||
$apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
|
||||
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $apiTokenListener);
|
||||
|
||||
$acceptJsonListener = new AcceptJsonRequestListener();
|
||||
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $acceptJsonListener);
|
||||
|
||||
$jsonContentTypeListener = new ContentTypeJsonRequestListener();
|
||||
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $jsonContentTypeListener);
|
||||
|
||||
$userAgentListener = new UserAgentRequestListener();
|
||||
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener);
|
||||
|
||||
$this->movieRepository = new MovieRepository($this->client);
|
||||
$this->tvRepository = new TvRepository($this->client);
|
||||
}
|
||||
|
||||
public function popularMovies(int $page = 1, ?int $limit = null)
|
||||
{
|
||||
$movies = $this->movieRepository->getPopular(['page' => $page]);
|
||||
|
||||
$movies = $movies->map(function ($movie) use ($movies) {
|
||||
return $this->parseResult($movies[$movie], "movie");
|
||||
});
|
||||
|
||||
$movies = Map::from($movies->toArray())->filter(function ($movie) {
|
||||
return $movie !== null
|
||||
&& $movie->imdbId !== null
|
||||
&& $movie->tmdbId !== null
|
||||
&& $movie->title !== null
|
||||
&& $movie->poster !== null
|
||||
&& $movie->description !== null
|
||||
&& $movie->mediaType !== null;
|
||||
});
|
||||
|
||||
$movies = array_values($movies->toArray());
|
||||
|
||||
if (null !== $limit) {
|
||||
$movies = array_slice($movies, 0, $limit);
|
||||
}
|
||||
|
||||
return $movies;
|
||||
}
|
||||
|
||||
public function popularTvShows(int $page = 1, ?int $limit = null)
|
||||
{
|
||||
$movies = $this->tvRepository->getPopular(['page' => $page]);
|
||||
|
||||
$movies = $movies->map(function ($movie) use ($movies) {
|
||||
return $this->parseResult($movies[$movie], "movie");
|
||||
});
|
||||
|
||||
$movies = Map::from($movies->toArray())->filter(function ($movie) {
|
||||
return $movie !== null
|
||||
&& $movie->imdbId !== null
|
||||
&& $movie->tmdbId !== null
|
||||
&& $movie->title !== null
|
||||
&& $movie->poster !== null
|
||||
&& $movie->description !== null
|
||||
&& $movie->mediaType !== null;
|
||||
});
|
||||
|
||||
$movies = array_values($movies->toArray());
|
||||
|
||||
if (null !== $limit) {
|
||||
$movies = array_slice($movies, 0, $limit);
|
||||
}
|
||||
|
||||
return $movies;
|
||||
}
|
||||
|
||||
public function search(string $term, int $page = 1)
|
||||
{
|
||||
$searchRepository = new SearchRepository($this->client);
|
||||
$searchResults = $searchRepository->searchMulti($term, new KeywordSearchQuery(['page' => $page]));
|
||||
|
||||
$results = [];
|
||||
foreach ($searchResults as $result) {
|
||||
if (!$result instanceof Movie && !$result instanceof Tv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = $this->parseResult($result);
|
||||
}
|
||||
|
||||
$results = array_filter($results, fn ($result) => null !== $result->imdbId);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function find(string $id)
|
||||
{
|
||||
$finder = new Find($this->client);
|
||||
$result = $finder->findBy($id, ['external_source' => 'imdb_id']);
|
||||
|
||||
if (count($result['movie_results']) > 0) {
|
||||
return $result['movie_results'][0]['id'];
|
||||
} elseif (count($result['tv_results']) > 0) {
|
||||
return $result['tv_results'][0]['id'];
|
||||
} elseif (count($result['tv_episode_results']) > 0) {
|
||||
return $result['tv_episode_results'][0]['show_id'];
|
||||
}
|
||||
|
||||
throw new \Exception("No results found for $id");
|
||||
}
|
||||
|
||||
public function findByImdbId(string $imdbId)
|
||||
{
|
||||
$finder = new Find($this->client);
|
||||
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
|
||||
|
||||
if (count($result['movie_results']) > 0) {
|
||||
$result = $result['movie_results'][0];
|
||||
$mediaType = MediaType::Movie->value;
|
||||
} elseif (count($result['tv_results']) > 0) {
|
||||
$result = $result['tv_results'][0];
|
||||
$mediaType = MediaType::TvShow->value;
|
||||
} elseif (count($result['tv_episode_results']) > 0) {
|
||||
$result = $result['tv_episode_results'][0];
|
||||
$mediaType = MediaType::TvShow->value;
|
||||
}
|
||||
|
||||
$result['media_type'] = $mediaType;
|
||||
$result = $this->mediaDetails($imdbId, $result['media_type']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function movieDetails(string $id)
|
||||
{
|
||||
$client = new MovieRepository($this->client);
|
||||
$details = $client->getApi()->getMovie($id, ['append_to_response' => 'external_ids']);
|
||||
return $this->parseResult($details, "movie");
|
||||
}
|
||||
|
||||
public function tvDetails(string $id)
|
||||
{
|
||||
$client = new TvRepository($this->client);
|
||||
$details = $client->getApi()->getTvshow($id, ['append_to_response' => 'external_ids,seasons']);
|
||||
$details = $this->getEpisodesFromSeries($details);
|
||||
return $this->parseResult($details, "tvshow");
|
||||
}
|
||||
|
||||
public function episodeDetails(string $id, string $season, string $episode)
|
||||
{
|
||||
$client = new TvEpisodeRepository($this->client);
|
||||
$result = $client->getApi()->getEpisode($id, $season, $episode, ['append_to_response' => 'external_ids']);
|
||||
return $this->parseResult($result, "episode");
|
||||
}
|
||||
|
||||
public function getEpisodesFromSeries(array $series)
|
||||
{
|
||||
$client = new TvSeasonRepository($this->client);
|
||||
foreach ($series['seasons'] as $season) {
|
||||
if ($season['episode_count'] <= 0 || $season['name'] === 'Specials') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$series['episodes'][$season['season_number']] = Map::from(
|
||||
$client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']
|
||||
)->map(function ($data) {
|
||||
$data['poster'] = (null !== $data['still_path']) ? self::POSTER_IMG_PATH . $data['still_path'] : null;
|
||||
return $data;
|
||||
})->toArray();
|
||||
}
|
||||
return $series;
|
||||
}
|
||||
|
||||
public function mediaDetails(string $id, string $type)
|
||||
{
|
||||
$id = $this->find($id);
|
||||
|
||||
if ($type === "movies") {
|
||||
return $this->movieDetails($id);
|
||||
} else {
|
||||
return $this->tvDetails($id);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseResult($result, $mediaType = null)
|
||||
{
|
||||
if (is_array($result)) {
|
||||
return $this->parseFromArray($result, $mediaType);
|
||||
} else {
|
||||
return $this->parseFromObject($result);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseFromArray($data, $mediaType)
|
||||
{
|
||||
if (null === $mediaType) {
|
||||
throw new \Exception("A media type must be set when parsing from an array.");
|
||||
}
|
||||
|
||||
if ($mediaType === 'movie') {
|
||||
$result = $this->parseMovie($data, self::POSTER_IMG_PATH);
|
||||
} elseif ($mediaType === 'tvshow') {
|
||||
$result = $this->parseTvShow($data, self::POSTER_IMG_PATH);
|
||||
} elseif ($mediaType === 'episode') {
|
||||
$result = $this->parseEpisode($data, self::POSTER_IMG_PATH);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
|
||||
{
|
||||
if (!in_array($data['first_air_date'], ['', null,])) {
|
||||
$airDate = (new \DateTime($data['first_air_date']))->format('Y-m-d');
|
||||
} else {
|
||||
$airDate = null;
|
||||
}
|
||||
return new TmdbResult(
|
||||
imdbId: $data['external_ids']['imdb_id'],
|
||||
tmdbId: $data['id'],
|
||||
title: $data['name'],
|
||||
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
|
||||
description: $data['overview'],
|
||||
year: $airDate,
|
||||
mediaType: "tvshows",
|
||||
episodes: $data['episodes'],
|
||||
);
|
||||
}
|
||||
|
||||
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
|
||||
{
|
||||
if (!in_array($data['air_date'], ['', null,])) {
|
||||
$airDate = (new \DateTime($data['air_date']))->format('Y-m-d');
|
||||
} else {
|
||||
$airDate = null;
|
||||
}
|
||||
return new TmdbResult(
|
||||
imdbId: $data['external_ids']['imdb_id'],
|
||||
tmdbId: $data['id'],
|
||||
title: $data['name'],
|
||||
poster: (null !== $data['still_path']) ? $posterBasePath . $data['still_path'] : null,
|
||||
description: $data['overview'],
|
||||
year: (new \DateTime($data['air_date']))->format('Y'),
|
||||
mediaType: "tvshows",
|
||||
episodes: null,
|
||||
episodeAirDate: $airDate,
|
||||
);
|
||||
}
|
||||
|
||||
private function parseMovie(array $data, string $posterBasePath): TmdbResult
|
||||
{
|
||||
if (!in_array($data['release_date'], ['', null,])) {
|
||||
$airDate = (new \DateTime($data['release_date']))->format('Y-m-d');
|
||||
} else {
|
||||
$airDate = null;
|
||||
}
|
||||
return new TmdbResult(
|
||||
imdbId: $data['external_ids']['imdb_id'],
|
||||
tmdbId: $data['id'],
|
||||
title: $data['title'],
|
||||
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
|
||||
description: $data['overview'],
|
||||
year: (new \DateTime($data['release_date']))->format('Y'),
|
||||
mediaType: "movies",
|
||||
episodeAirDate: $airDate,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private function parseFromObject($result): TmdbResult
|
||||
{
|
||||
$mediaType = $result instanceof Movie ? MediaType::Movie->value : MediaType::TvShow->value;
|
||||
$tmdbResult = new TmdbResult();
|
||||
$tmdbResult->mediaType = $mediaType;
|
||||
$tmdbResult->tmdbId = $result->getId();
|
||||
$tmdbResult->imdbId = $this->getImdbId($result->getId(), $mediaType);
|
||||
$tmdbResult->title = $this->getTitle($result, $mediaType);
|
||||
$tmdbResult->poster = self::POSTER_IMG_PATH . $result->getPosterImage();
|
||||
$tmdbResult->year = $this->getReleaseDate($result, $mediaType);
|
||||
$tmdbResult->description = $result->getOverview();
|
||||
return $tmdbResult;
|
||||
}
|
||||
|
||||
public function getImdbId(string $tmdbId, $mediaType)
|
||||
{
|
||||
$externalIds = $this->cache->get("tmdb.externalIds.{$tmdbId}",
|
||||
function (ItemInterface $item) use ($tmdbId, $mediaType) {
|
||||
switch (MediaType::tryFrom($mediaType)->value) {
|
||||
case MediaType::Movie->value:
|
||||
return $this->movieRepository->getExternalIds($tmdbId);
|
||||
case MediaType::TvShow->value:
|
||||
return $this->tvRepository->getExternalIds($tmdbId);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if (null === $externalIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $externalIds->getImdbId() !== "" ? $externalIds->getImdbId() : "null";
|
||||
}
|
||||
|
||||
public function getImages($tmdbId, $mediaType)
|
||||
{
|
||||
return $this->cache->get("tmdb.images.{$tmdbId}",
|
||||
function (ItemInterface $item) use ($tmdbId, $mediaType) {
|
||||
switch (MediaType::tryFrom($mediaType)->value) {
|
||||
case MediaType::Movie->value:
|
||||
return $this->movieRepository->getImages($tmdbId);
|
||||
case MediaType::TvShow->value:
|
||||
return $this->tvRepository->getImages($tmdbId);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function getReleaseDate($result, $mediaType): string
|
||||
{
|
||||
switch (MediaType::tryFrom($mediaType)->value) {
|
||||
case MediaType::Movie->value:
|
||||
return ($result->getReleaseDate() instanceof \DateTime)
|
||||
? $result->getReleaseDate()->format('Y')
|
||||
: $result->getReleaseDate();
|
||||
case MediaType::TvShow->value:
|
||||
return ($result->getFirstAirDate() instanceof \DateTime)
|
||||
? $result->getFirstAirDate()->format('Y')
|
||||
: $result->getFirstAirDate();
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private function getTitle($result, $mediaType): string
|
||||
{
|
||||
switch (MediaType::tryFrom($mediaType)->value) {
|
||||
case MediaType::Movie->value:
|
||||
return $result->getTitle();
|
||||
case MediaType::TvShow->value:
|
||||
return $result->getName();
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
276
src/Tmdb/TmdbClient.php
Normal file
276
src/Tmdb/TmdbClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
55
src/Torrentio/Client/HttpClient.php
Normal file
55
src/Torrentio/Client/HttpClient.php
Normal 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 [];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = "-",
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ final class TvEpisodeList
|
||||
}
|
||||
|
||||
$this->reloadCount++;
|
||||
|
||||
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
29
src/User/Action/Input/SaveUserCalendarPreferencesInput.php
Normal file
29
src/User/Action/Input/SaveUserCalendarPreferencesInput.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\User\Framework\Entity;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\EventLog\Framework\Entity\EventLog;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\User\Framework\Repository\UserRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -56,11 +57,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\OneToMany(targetEntity: Download::class, mappedBy: 'user')]
|
||||
private Collection $downloads;
|
||||
|
||||
/**
|
||||
* @var Collection<int, EventLog>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: EventLog::class, mappedBy: 'user')]
|
||||
private Collection $eventLogs;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userPreferences = new ArrayCollection();
|
||||
$this->monitors = new ArrayCollection();
|
||||
$this->downloads = new ArrayCollection();
|
||||
$this->eventLogs = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -327,4 +335,49 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, EventLog>
|
||||
*/
|
||||
public function getEventLogs(): Collection
|
||||
{
|
||||
return $this->eventLogs;
|
||||
}
|
||||
|
||||
public function addEventLog(EventLog $eventLog): static
|
||||
{
|
||||
if (!$this->eventLogs->contains($eventLog)) {
|
||||
$this->eventLogs->add($eventLog);
|
||||
$eventLog->setUser($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeEventLog(EventLog $eventLog): static
|
||||
{
|
||||
if ($this->eventLogs->removeElement($eventLog)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($eventLog->getUser() === $this) {
|
||||
$eventLog->setUser(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
templates/bundles/TwigBundle/Exception/error.html.twig
Normal file
17
templates/bundles/TwigBundle/Exception/error.html.twig
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'bare.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">{{ }}</h2>
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
|
||||
<div class="flex flex-col m-0 text-center">
|
||||
<h3 class="text-2xl text-bold text-center text-gray-50">Oh crap!</h3>
|
||||
<small>(or is it?)</small>
|
||||
</div>
|
||||
<p class="mb-2">There are many things I'm capable of, but this ain't one of 'em!</p>
|
||||
<pre class="bg-gray-800 text-white p-4 rounded-md overflow-x-auto">
|
||||
<code class="language-plaintext">
|
||||
{{ exception.message }}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
templates/bundles/TwigBundle/Exception/error404.html.twig
Normal file
13
templates/bundles/TwigBundle/Exception/error404.html.twig
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends 'bare.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">404</h2>
|
||||
<div class="flex flex-col bg-orange-500/50 p-4 rounded-lg gap-4 w-full md:w-[420px] border-orange-500 border-2 text-gray-50 animate-fade">
|
||||
<div class="flex flex-col m-0 text-center">
|
||||
<h3 class="text-2xl text-bold text-center text-gray-50">It's not you, it's me!</h3>
|
||||
<small>(or is it?)</small>
|
||||
</div>
|
||||
<p class="mb-2">I don't know, maybe I used to have that page-maybe I didn't, but one thing's for sure: I
|
||||
don't have it now. Sorry!</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user