Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2effa0fb07 | ||
|
|
c2474942a1 | ||
|
|
c175dddede | ||
|
|
0e1d8e15e3 | ||
|
|
d38f8ba4be | ||
|
|
e20def1325 | ||
|
|
ed69ed61b8 | ||
|
|
9c8e625316 | ||
|
|
ef6ed20a0b | ||
|
|
da0eab652b | ||
|
|
17de41dc57 | ||
|
|
d2eaccaf93 | ||
|
|
37e13347b2 | ||
|
|
7dd40b4525 | ||
|
|
2315b995e0 | ||
|
|
83401f2a7a | ||
|
|
50bcb4e1df | ||
|
|
ea569b480d | ||
|
|
50ec0c1d6f | ||
|
|
4ae70115b5 | ||
|
|
f4982af991 | ||
|
|
f253b33910 | ||
|
|
ec0d2a198c | ||
|
|
1f1c6f775f | ||
|
|
cd14a197aa | ||
|
|
a9031df3c3 | ||
|
|
55ab9d840e | ||
|
|
3001e85715 | ||
|
|
f4125cc37c | ||
|
|
a3408d9852 | ||
|
|
0048423a46 | ||
|
|
2468e4d5b6 | ||
|
|
445224d368 | ||
|
|
9a660279be | ||
|
|
c1adedf74d | ||
|
|
9a0e7fce26 | ||
|
|
d90b4d7863 | ||
|
|
2860d2e949 | ||
|
|
ad2bbfd48c | ||
|
|
5e306c6740 | ||
|
|
56129de3f9 | ||
|
|
22b2b46da5 | ||
|
|
7cc48ffc73 | ||
|
|
adb79db8f5 | ||
|
|
9ca87af938 | ||
|
|
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 |
@@ -12,3 +12,4 @@ phpstan.dist.neon
|
||||
phpunit.dist.xml
|
||||
nomad.deploy.hcl
|
||||
deployment.properties
|
||||
var/download/*
|
||||
|
||||
10
.env
10
.env
@@ -58,3 +58,13 @@ OIDC_BYPASS_FORM_LOGIN=false
|
||||
|
||||
NOTIFICATION_TRANSPORT=
|
||||
NTFY_DSN=
|
||||
|
||||
###> sentry/sentry-symfony ###
|
||||
SENTRY_DSN=
|
||||
SENTRY_JS_URL=
|
||||
###< sentry/sentry-symfony ###
|
||||
|
||||
# TMDB 'with_original_language' option
|
||||
# - only include media originally
|
||||
# produced in this language
|
||||
TMDB_ORIGINAL_LANGUAGE=en
|
||||
|
||||
24
.gitea/workflows/sonarqube_scans.yml
Normal file
24
.gitea/workflows/sonarqube_scans.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
name: SonarQube Scan
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube Trigger
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checking out
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: SonarQube Scan
|
||||
uses: https://code.caldwell.digital/tools/sonarqube-action@v0.0.3
|
||||
with:
|
||||
host: "https://qube.caldwell.digital"
|
||||
login: ${{ secrets.SONARQUBE_TOKEN }}
|
||||
projectName: "torsearch"
|
||||
projectBaseDir: "./src"
|
||||
@@ -67,6 +67,9 @@ export default class MonitorListRow extends HTMLTableRowElement {
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Downloaded At</div>
|
||||
</th>
|
||||
<th class="px-4 py-2">
|
||||
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Air Date</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -107,6 +110,9 @@ export default class MonitorListRow extends HTMLTableRowElement {
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('downloaded-at') ?? "-"}</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('air-date') ?? "-"}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -28,6 +28,9 @@ export default class PreviewContentDialog extends HTMLDialogElement {
|
||||
}
|
||||
|
||||
display({ heading, content }) {
|
||||
if (this.hasAttribute('mdWidth')) {
|
||||
this.style.width = this.getAttribute('mdWidth');
|
||||
}
|
||||
this.setHeading(heading);
|
||||
this.setContent(content);
|
||||
this.showModal();
|
||||
|
||||
54
assets/controllers/discover_media_results_controller.js
Normal file
54
assets/controllers/discover_media_results_controller.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* The following line makes this controller "lazy": it won't be downloaded until needed
|
||||
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
|
||||
*/
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller {
|
||||
static targets = ['poster', 'moreBtn', 'moreLink']
|
||||
|
||||
moreResultsClicks = 0;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
moreResults() {
|
||||
const elems = this.posterTargets.filter(poster => poster.classList.contains('hidden'));
|
||||
if (this.moreResultsClicks <= 2) {
|
||||
elems.slice(0, 6).forEach(poster => poster.classList.remove('hidden'));
|
||||
this.moreResultsClicks++;
|
||||
|
||||
if (this.moreResultsClicks === 2) {
|
||||
this.moreBtnTarget.classList.add('hidden');
|
||||
this.moreLinkTarget.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
if (null !== content && undefined !== content && "" !== content) {
|
||||
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
|
||||
if (['', null, undefined].includes(monitor.getAttribute('parent-id'))) {
|
||||
window.location.href = `/monitors/${monitor.getAttribute('monitor-id')}`;
|
||||
} else {
|
||||
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
1
assets/icons/bi/camera-video.svg
Normal file
1
assets/icons/bi/camera-video.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2zm11.5 5.175l3.5 1.556V4.269l-3.5 1.556zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"/></svg>
|
||||
|
After Width: | Height: | Size: 374 B |
1
assets/icons/bi/cloud-download.svg
Normal file
1
assets/icons/bi/cloud-download.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773C16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318C1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593c.143-.863.698-1.723 1.464-2.383"/><path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 720 B |
1
assets/icons/bi/download.svg
Normal file
1
assets/icons/bi/download.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 16 16"><g fill="currentColor"><path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/><path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
assets/icons/fluent-mdl2/back.svg
Normal file
1
assets/icons/fluent-mdl2/back.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path fill="currentColor" d="M2048 1088H250l787 787l-90 90L6 1024L947 83l90 90l-787 787h1798z"/></svg>
|
||||
|
After Width: | Height: | Size: 167 B |
@@ -1,14 +1,22 @@
|
||||
# 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 buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
|
||||
docker build -f docker/Dockerfile.base.app -t code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base:latest --build-arg "FRANKENPHP_TAG=${APP_FRANKENPHP_TAG}" .
|
||||
docker push code.caldwell.digital/home/torsearch-base:${APP_FRANKENPHP_TAG}
|
||||
docker push code.caldwell.digital/home/torsearch-base:latest
|
||||
|
||||
# torsearch-worker & torsearch-scheduler are built from this base
|
||||
export WORKER_FRANKENPHP_TAG=php8.4-alpine
|
||||
|
||||
docker buildx build --platform=linux/amd64 -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 buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
|
||||
docker build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG} -t code.caldwell.digital/home/torsearch-base-worker:latest --build-arg "FRANKENPHP_TAG=${WORKER_FRANKENPHP_TAG}" .
|
||||
docker push code.caldwell.digital/home/torsearch-base-worker:${WORKER_FRANKENPHP_TAG}
|
||||
docker push code.caldwell.digital/home/torsearch-base-worker:latest
|
||||
|
||||
# torsearch-worker-supervisord
|
||||
export ALPINE_VERSION=3.22
|
||||
|
||||
#docker buildx build --platform=linux/amd64,linux/arm64 -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
|
||||
docker build -f docker/Dockerfile.base.worker -t code.caldwell.digital/home/torsearch-base-worker-supervisord:latest --build-arg "ALPINE_VERSION=${ALPINE_VERSION}" .
|
||||
docker push code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||
|
||||
17
compose.yml
17
compose.yml
@@ -34,10 +34,8 @@ services:
|
||||
|
||||
worker:
|
||||
build:
|
||||
dockerfile: docker/Dockerfile.base.worker
|
||||
dockerfile: docker/Dockerfile.worker
|
||||
context: .
|
||||
args:
|
||||
FRANKENPHP_TAG: php8.4-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- $PWD:/app
|
||||
@@ -45,22 +43,19 @@ services:
|
||||
tty: true
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
command: php /app/bin/console messenger:consume async --time-limit=3600 -vv
|
||||
|
||||
|
||||
scheduler:
|
||||
build:
|
||||
dockerfile: docker/Dockerfile.base.worker
|
||||
dockerfile: docker/Dockerfile.scheduler
|
||||
context: .
|
||||
args:
|
||||
FRANKENPHP_TAG: php8.4-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- $PWD:/app
|
||||
- $PWD/var/download:/var/download
|
||||
tty: true
|
||||
environment:
|
||||
TZ: America/Chicago
|
||||
command: php /app/bin/console messenger:consume scheduler_monitor -vv
|
||||
tty: true
|
||||
WORKER_MONITOR: 2
|
||||
|
||||
|
||||
redis:
|
||||
@@ -96,6 +91,8 @@ services:
|
||||
image: adminer
|
||||
ports:
|
||||
- "8081:8080"
|
||||
environment:
|
||||
ADMINER_DEFAULT_SERVER: database
|
||||
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"phpstan/phpdoc-parser": "^2.1",
|
||||
"predis/predis": "^2.4",
|
||||
"runtime/frankenphp-symfony": "^0.2.0",
|
||||
"sentry/sentry-symfony": "^5.6",
|
||||
"spatie/icalendar-generator": "^3.0",
|
||||
"spomky-labs/pwa-bundle": "^1.2",
|
||||
"stof/doctrine-extensions-bundle": "^1.14",
|
||||
@@ -46,6 +47,7 @@
|
||||
"symfony/mailer": "7.3.*",
|
||||
"symfony/mercure-bundle": "^0.3.9",
|
||||
"symfony/messenger": "7.3.*",
|
||||
"symfony/monolog-bundle": "^3.10",
|
||||
"symfony/notifier": "7.3.*",
|
||||
"symfony/ntfy-notifier": "7.3.*",
|
||||
"symfony/object-mapper": "7.3.*",
|
||||
|
||||
601
composer.lock
generated
601
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": "e055bbbbe5836c92bb147b6dbb1d1d46",
|
||||
"content-hash": "952def2c32d975032ac0061e5aa37319",
|
||||
"packages": [
|
||||
{
|
||||
"name": "1tomany/rich-bundle",
|
||||
@@ -2552,6 +2552,66 @@
|
||||
],
|
||||
"time": "2025-03-27T12:30:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.5.0",
|
||||
@@ -2681,6 +2741,109 @@
|
||||
},
|
||||
"time": "2025-02-06T08:48:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Seldaek/monolog.git",
|
||||
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6",
|
||||
"reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"psr/log": "^2.0 || ^3.0"
|
||||
},
|
||||
"provide": {
|
||||
"psr/log-implementation": "3.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"aws/aws-sdk-php": "^3.0",
|
||||
"doctrine/couchdb": "~1.0@dev",
|
||||
"elasticsearch/elasticsearch": "^7 || ^8",
|
||||
"ext-json": "*",
|
||||
"graylog2/gelf-php": "^1.4.2 || ^2.0",
|
||||
"guzzlehttp/guzzle": "^7.4.5",
|
||||
"guzzlehttp/psr7": "^2.2",
|
||||
"mongodb/mongodb": "^1.8",
|
||||
"php-amqplib/php-amqplib": "~2.4 || ^3",
|
||||
"php-console/php-console": "^3.1.8",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpstan/phpstan-deprecation-rules": "^2",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
|
||||
"predis/predis": "^1.1 || ^2",
|
||||
"rollbar/rollbar": "^4.0",
|
||||
"ruflin/elastica": "^7 || ^8",
|
||||
"symfony/mailer": "^5.4 || ^6",
|
||||
"symfony/mime": "^5.4 || ^6"
|
||||
},
|
||||
"suggest": {
|
||||
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
|
||||
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
|
||||
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
|
||||
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
|
||||
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
|
||||
"ext-mbstring": "Allow to work properly with unicode symbols",
|
||||
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
|
||||
"ext-openssl": "Required to send log messages using SSL",
|
||||
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
|
||||
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
|
||||
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
|
||||
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
|
||||
"rollbar/rollbar": "Allow sending log messages to Rollbar",
|
||||
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Monolog\\": "src/Monolog"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "https://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
|
||||
"homepage": "https://github.com/Seldaek/monolog",
|
||||
"keywords": [
|
||||
"log",
|
||||
"logging",
|
||||
"psr-3"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Seldaek/monolog/issues",
|
||||
"source": "https://github.com/Seldaek/monolog/tree/3.9.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Seldaek",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-03-24T10:02:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "3.10.0",
|
||||
@@ -4757,6 +4920,196 @@
|
||||
],
|
||||
"time": "2023-12-12T12:06:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.17.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/5c696b8de57e841a2bf3b6f6eecfd99acfdda80c",
|
||||
"reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/promises": "^2.0.3",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"phpbench/phpbench": "^1.0",
|
||||
"phpstan/phpstan": "^1.3",
|
||||
"phpunit/phpunit": "^8.5|^9.6",
|
||||
"vimeo/psalm": "^4.17"
|
||||
},
|
||||
"suggest": {
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.17.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2025-10-23T15:19:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-symfony",
|
||||
"version": "5.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-symfony.git",
|
||||
"reference": "9867751f5091b55d7e3a223f48d88e228132e073"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/9867751f5091b55d7e3a223f48d88e228132e073",
|
||||
"reference": "9867751f5091b55d7e3a223f48d88e228132e073",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/psr7": "^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5||^2.0",
|
||||
"php": "^7.2||^8.0",
|
||||
"sentry/sentry": "^4.16.0",
|
||||
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
|
||||
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/polyfill-php80": "^1.22",
|
||||
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^2.13||^3.3||^4.0",
|
||||
"doctrine/doctrine-bundle": "^2.6",
|
||||
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
|
||||
"masterminds/html5": "^2.8",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "1.12.5",
|
||||
"phpstan/phpstan-phpunit": "1.4.0",
|
||||
"phpstan/phpstan-symfony": "1.4.10",
|
||||
"phpunit/phpunit": "^8.5.40||^9.6.21",
|
||||
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/monolog-bundle": "^3.4",
|
||||
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0",
|
||||
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0",
|
||||
"vimeo/psalm": "^4.3||^5.16.0"
|
||||
},
|
||||
"suggest": {
|
||||
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
|
||||
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
|
||||
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/aliases.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\SentryBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Symfony integration for Sentry (http://getsentry.com)",
|
||||
"homepage": "http://getsentry.com",
|
||||
"keywords": [
|
||||
"errors",
|
||||
"logging",
|
||||
"sentry",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-symfony/issues",
|
||||
"source": "https://github.com/getsentry/sentry-symfony/tree/5.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-24T13:41:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "spatie/icalendar-generator",
|
||||
"version": "3.0.0",
|
||||
@@ -7637,6 +7990,169 @@
|
||||
],
|
||||
"time": "2025-02-19T08:51:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/monolog-bridge",
|
||||
"version": "v7.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/monolog-bridge.git",
|
||||
"reference": "c66a65049c75f3ddf03d73c8c9ed61405779ce47"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c66a65049c75f3ddf03d73c8c9ed61405779ce47",
|
||||
"reference": "c66a65049c75f3ddf03d73c8c9ed61405779ce47",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"monolog/monolog": "^3",
|
||||
"php": ">=8.2",
|
||||
"symfony/http-kernel": "^6.4|^7.0",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.4",
|
||||
"symfony/http-foundation": "<6.4",
|
||||
"symfony/security-core": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0",
|
||||
"symfony/http-client": "^6.4|^7.0",
|
||||
"symfony/mailer": "^6.4|^7.0",
|
||||
"symfony/messenger": "^6.4|^7.0",
|
||||
"symfony/mime": "^6.4|^7.0",
|
||||
"symfony/security-core": "^6.4|^7.0",
|
||||
"symfony/var-dumper": "^6.4|^7.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\Monolog\\": ""
|
||||
},
|
||||
"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 integration for Monolog with various Symfony components",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/monolog-bridge/tree/v7.3.5"
|
||||
},
|
||||
"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-10-14T19:16:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/monolog-bundle",
|
||||
"version": "v3.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/monolog-bundle.git",
|
||||
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
|
||||
"reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"monolog/monolog": "^1.25.1 || ^2.0 || ^3.0",
|
||||
"php": ">=7.2.5",
|
||||
"symfony/config": "^5.4 || ^6.0 || ^7.0",
|
||||
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0",
|
||||
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0",
|
||||
"symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^5.4 || ^6.0 || ^7.0",
|
||||
"symfony/phpunit-bridge": "^6.3 || ^7.0",
|
||||
"symfony/yaml": "^5.4 || ^6.0 || ^7.0"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bundle\\MonologBundle\\": ""
|
||||
},
|
||||
"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": "Symfony MonologBundle",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"log",
|
||||
"logging"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/symfony/monolog-bundle/issues",
|
||||
"source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2023-11-06T17:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/notifier",
|
||||
"version": "v7.3.0",
|
||||
@@ -8777,6 +9293,89 @@
|
||||
],
|
||||
"time": "2025-04-04T13:12:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
"version": "v7.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
||||
"reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
|
||||
"reference": "03f2f72319e7acaf2a9f6fcbe30ef17eec51594f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"psr/http-message": "^1.0|^2.0",
|
||||
"symfony/http-foundation": "^6.4|^7.0"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15",
|
||||
"symfony/http-kernel": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"nyholm/psr7": "^1.1",
|
||||
"php-http/discovery": "^1.15",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/browser-kit": "^6.4|^7.0",
|
||||
"symfony/config": "^6.4|^7.0",
|
||||
"symfony/event-dispatcher": "^6.4|^7.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0",
|
||||
"symfony/http-kernel": "^6.4|^7.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
||||
},
|
||||
"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": "PSR HTTP message bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v7.3.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-26T08:57:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v7.3.0",
|
||||
|
||||
@@ -23,4 +23,6 @@ return [
|
||||
SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true],
|
||||
Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true],
|
||||
SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true],
|
||||
Sentry\SentryBundle\SentryBundle::class => ['prod' => true, 'dev' => true],
|
||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -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:
|
||||
|
||||
63
config/packages/monolog.yaml
Normal file
63
config/packages/monolog.yaml
Normal file
@@ -0,0 +1,63 @@
|
||||
monolog:
|
||||
channels:
|
||||
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
|
||||
|
||||
when@dev:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
channels: ["!event"]
|
||||
# uncomment to get logging in your browser
|
||||
# you may have to allow bigger header sizes in your Web server configuration
|
||||
#firephp:
|
||||
# type: firephp
|
||||
# level: info
|
||||
#chromephp:
|
||||
# type: chromephp
|
||||
# level: info
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine", "!console"]
|
||||
|
||||
when@test:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!event"]
|
||||
nested:
|
||||
type: stream
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
level: debug
|
||||
|
||||
when@prod:
|
||||
monolog:
|
||||
handlers:
|
||||
main:
|
||||
type: fingers_crossed
|
||||
action_level: error
|
||||
handler: nested
|
||||
excluded_http_codes: [404, 405]
|
||||
channels: ["!deprecation"]
|
||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||
nested:
|
||||
type: stream
|
||||
path: php://stderr
|
||||
level: debug
|
||||
formatter: monolog.formatter.json
|
||||
console:
|
||||
type: console
|
||||
process_psr_3_messages: false
|
||||
channels: ["!event", "!doctrine"]
|
||||
deprecation:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: php://stderr
|
||||
formatter: monolog.formatter.json
|
||||
40
config/packages/sentry.yaml
Normal file
40
config/packages/sentry.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
sentry:
|
||||
register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry
|
||||
register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK
|
||||
|
||||
options:
|
||||
release: 'torsearch@%app.version%'
|
||||
enable_logs: true
|
||||
traces_sample_rate: 1
|
||||
profiles_sample_rate: 1
|
||||
attach_stacktrace: true
|
||||
|
||||
tracing:
|
||||
enabled: true
|
||||
dbal: # DB queries
|
||||
enabled: true
|
||||
cache: # cache pools
|
||||
enabled: true
|
||||
twig: # templating engine
|
||||
enabled: true
|
||||
|
||||
services:
|
||||
# (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler)
|
||||
Sentry\Monolog\BreadcrumbHandler:
|
||||
arguments:
|
||||
- '@Sentry\State\HubInterface'
|
||||
- !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs
|
||||
monolog:
|
||||
handlers:
|
||||
# (Optionally) Register the breadcrumb handler as a Monolog handler
|
||||
sentry_breadcrumbs:
|
||||
type: service
|
||||
name: sentry_breadcrumbs
|
||||
id: Sentry\Monolog\BreadcrumbHandler
|
||||
# Register the handler as a Monolog handler to capture messages as events
|
||||
sentry:
|
||||
type: sentry
|
||||
level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events
|
||||
hub_id: Sentry\State\HubInterface
|
||||
fill_extra_context: true # Enables sending monolog context to Sentry
|
||||
process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages
|
||||
@@ -1,6 +1,7 @@
|
||||
twig:
|
||||
globals:
|
||||
version: '%app.version%'
|
||||
sentry_javascript_url: '%sentry.javascript_url%'
|
||||
file_name_pattern: '*.twig'
|
||||
date:
|
||||
format: 'm/d/Y'
|
||||
|
||||
@@ -6,6 +6,22 @@ controllersBase:
|
||||
defaults:
|
||||
schemes: [ 'https' ]
|
||||
|
||||
controllersDiscover:
|
||||
resource:
|
||||
path: ../src/Discover/Framework/Controller/
|
||||
namespace: App\Discover\Framework\Controller
|
||||
type: attribute
|
||||
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/
|
||||
|
||||
@@ -48,6 +48,10 @@ parameters:
|
||||
notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
|
||||
notification.ntfy.dsn: '%env(NTFY_DSN)%'
|
||||
|
||||
# Sentry
|
||||
sentry.dsn: '%env(SENTRY_DSN)%'
|
||||
sentry.javascript_url: '%env(SENTRY_JS_URL)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
|
||||
@@ -13,12 +13,6 @@ services:
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
- mercure_data:/data
|
||||
- mercure_config:/config
|
||||
depends_on:
|
||||
- database
|
||||
logging:
|
||||
driver: "gelf"
|
||||
options:
|
||||
gelf-address: "tcp://192.168.1.197:12202"
|
||||
|
||||
|
||||
worker:
|
||||
@@ -26,16 +20,10 @@ services:
|
||||
volumes:
|
||||
- /mnt/media/downloads/movies:/var/download/movies
|
||||
- /mnt/media/downloads/tvshows:/var/download/tvshows
|
||||
restart: always
|
||||
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:
|
||||
@@ -43,28 +31,10 @@ services:
|
||||
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"
|
||||
|
||||
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --maxmemory 512MB
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
volumes:
|
||||
mysql:
|
||||
mercure_config:
|
||||
mercure_data:
|
||||
redis_data:
|
||||
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
ARG FRANKENPHP_TAG
|
||||
#####
|
||||
# This version of Torsearch runs the scheduler, downloader, and worker
|
||||
# in a single container. Each process is managerd by supervisord
|
||||
# and can be configured via environment variables and more
|
||||
# than one of these containers cans till be run.
|
||||
#####
|
||||
ARG ALPINE_VERSION="3.22"
|
||||
FROM alpine:${ALPINE_VERSION} AS build_stage
|
||||
|
||||
FROM dunglas/frankenphp:${FRANKENPHP_TAG}
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
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 \
|
||||
php84-xml \
|
||||
supervisor
|
||||
|
||||
ENV SERVER_NAME=":80"
|
||||
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
|
||||
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
|
||||
RUN ln -s /usr/bin/php84 /usr/bin/php
|
||||
|
||||
ARG APP_VERSION="0.dev"
|
||||
ENV APP_VERSION="${APP_VERSION}"
|
||||
RUN mkdir -p /etc/supervisor/conf.d
|
||||
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
gd \
|
||||
intl \
|
||||
zip \
|
||||
opcache
|
||||
# We start supervisord and supervisord starts
|
||||
# respective service in the configuration file.
|
||||
ENTRYPOINT ["/app/bin/console", "init:worker"]
|
||||
|
||||
# Message transports can be enabled by passing them as command options.
|
||||
# Inlcluding a number with the option will start that amount of processes
|
||||
# for the transport. Not supplying a number will default to 1.
|
||||
# --download --monitor OR --download 2 --monitor
|
||||
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
|
||||
###
|
||||
# This version of Torsearch can run the scheduler, downloader, and
|
||||
# worker in one container. Each process is managerd by supervisord
|
||||
# and can be configured via environment variables, and more than
|
||||
# one of these containers can still be run.
|
||||
###
|
||||
|
||||
ARG APP_VERSION="0.0.0-dev"
|
||||
ENV APP_VERSION="${APP_VERSION}"
|
||||
# Default to latest, but should pass in a version
|
||||
ARG APP_VERSION="latest"
|
||||
|
||||
COPY . /app
|
||||
# Set aside the actual app image so we can copy the app from there
|
||||
FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
|
||||
|
||||
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "scheduler_monitor" ]
|
||||
# Start with our base worker image
|
||||
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||
|
||||
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0
|
||||
# Set the APP_VERSION in the image
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
# Copy the actual application code from the previously built app
|
||||
COPY --chown=1000:1000 --from=app_image /app /app
|
||||
|
||||
# To retain backwards compatibility, default to async & download transports
|
||||
CMD [ "--monitor" ]
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
FROM code.caldwell.digital/home/torsearch-base-worker:php8.4-alpine
|
||||
###
|
||||
# This version of Torsearch can run the scheduler, downloader, and
|
||||
# worker in one container. Each process is managerd by supervisord
|
||||
# and can be configured via environment variables, and more than
|
||||
# one of these containers can still be run.
|
||||
###
|
||||
|
||||
ARG APP_VERSION="0.0.0-dev"
|
||||
ENV APP_VERSION="${APP_VERSION}"
|
||||
# Default to latest, but should pass in a version
|
||||
ARG APP_VERSION="latest"
|
||||
|
||||
COPY . /app
|
||||
# Set aside the actual app image so we can copy the app from there
|
||||
FROM code.caldwell.digital/home/torsearch-app:${APP_VERSION} AS app_image
|
||||
|
||||
ENTRYPOINT [ "php", "/app/bin/console", "messenger:consume", "async" ]
|
||||
# Start with our base worker image
|
||||
FROM code.caldwell.digital/home/torsearch-base-worker-supervisord:latest
|
||||
|
||||
HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD return 0
|
||||
# Set the APP_VERSION in the image
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
# Copy the actual application code from the previously built app
|
||||
COPY --chown=1000:1000 --from=app_image /app /app
|
||||
|
||||
# To retain backwards compatibility, default to async & download transports
|
||||
CMD [ "--async", "--download" ]
|
||||
|
||||
10
docker/worker/async.conf
Normal file
10
docker/worker/async.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
[program:torsearch-worker]
|
||||
command=/app/bin/console messenger:consume async -vv --time-limit 3600
|
||||
numprocs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=3
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
10
docker/worker/download.conf
Normal file
10
docker/worker/download.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
[program:torsearch-downloader]
|
||||
command=/app/bin/console messenger:consume download -vv --time-limit 3600
|
||||
numprocs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=3
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
10
docker/worker/monitor.conf
Normal file
10
docker/worker/monitor.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
[program:torsearch-scheduler]
|
||||
command=/app/bin/console messenger:consume scheduler_monitor monitor -vv --time-limit 21600
|
||||
numprocs=1
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autorestart=true
|
||||
startretries=3
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
5
docker/worker/supervisord.conf
Normal file
5
docker/worker/supervisord.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/run/supervisord.pid
|
||||
@@ -21,10 +21,7 @@ final class Version20250831013403 extends AbstractMigration
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE sessions
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
|
||||
DROP TABLE IF EXISTS sessions
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
|
||||
@@ -37,9 +34,6 @@ final class Version20250831013403 extends AbstractMigration
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
|
||||
SQL);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
34
migrations/Version20251106045808.php
Normal file
34
migrations/Version20251106045808.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?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 Version20251106045808 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE monitor ADD poster 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'
|
||||
ALTER TABLE monitor DROP poster
|
||||
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>
|
||||
@@ -53,6 +53,15 @@ final class ConfigResolver
|
||||
|
||||
#[Autowire(param: 'notification.ntfy.dsn')]
|
||||
private ?string $notificationNtfyDsn = null,
|
||||
|
||||
#[Autowire(param: 'sentry.dsn')]
|
||||
private ?string $sentryDsn = null,
|
||||
|
||||
#[Autowire(param: 'sentry.environment')]
|
||||
private ?string $sentryEnvironment = null,
|
||||
|
||||
#[Autowire(param: 'sentry.javascript_url')]
|
||||
private ?string $sentryJavascriptUrl = null,
|
||||
) {}
|
||||
|
||||
public function validate(): bool
|
||||
@@ -120,4 +129,13 @@ final class ConfigResolver
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function getSentryConfig()
|
||||
{
|
||||
return [
|
||||
'dsn' => $this->sentryDsn,
|
||||
'environment' => $this->sentryEnvironment,
|
||||
'javascript_url' => $this->sentryJavascriptUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
90
src/Base/Framework/Command/InitWorker.php
Normal file
90
src/Base/Framework/Command/InitWorker.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Base\Framework\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
class InitWorker extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('init:worker')
|
||||
->addOption('async', null, InputOption::VALUE_OPTIONAL, 'Run the async worker.',false)
|
||||
->addOption('download', null, InputOption::VALUE_OPTIONAL, 'Run the download worker.', false)
|
||||
->addOption('monitor', null, InputOption::VALUE_OPTIONAL, 'Run the monitor worker.', false)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$configFile = $this->twig->render("config/supervisord.conf.twig", []);
|
||||
|
||||
if ($this->optionExists($input, $output, 'async')) {
|
||||
$configFile .= $this->twig->render("config/async.conf.twig", [
|
||||
'replicas' => $this->getOptionValue('async', $input),
|
||||
]) . "\n\n";
|
||||
}
|
||||
|
||||
if ($this->optionExists($input, $output, 'download')) {
|
||||
$configFile .= $this->twig->render("config/download.conf.twig", [
|
||||
'replicas' => $this->getOptionValue('download', $input),
|
||||
]). "\n\n";
|
||||
}
|
||||
|
||||
if ($this->optionExists($input, $output, 'monitor')) {
|
||||
$configFile .= $this->twig->render("config/monitor.conf.twig", [
|
||||
'replicas' => $this->getOptionValue('monitor', $input),
|
||||
]). "\n\n";
|
||||
}
|
||||
|
||||
if ("" !== $configFile) {
|
||||
$output->writeln("[init:worker] Writing /etc/supervisor/conf.d/supervisord.conf");
|
||||
file_put_contents("/etc/supervisor/conf.d/supervisord.conf", $configFile);
|
||||
|
||||
$output->writeln("[init:worker] Starting supervisord");
|
||||
shell_exec("/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$output->writeln("[init:worker] No workers selected. Exiting.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
private function optionExists(InputInterface $input, OutputInterface $output, string $option): bool
|
||||
{
|
||||
if ($input->getOption($option) !== false) {
|
||||
$value = $input->getOption($option) ?? 1;
|
||||
$output->writeln("[init:worker] transport: $option // $value // input var");;
|
||||
return true;
|
||||
}
|
||||
|
||||
$optionKey = 'WORKER_' . strtoupper($option);
|
||||
if (array_key_exists($optionKey, $_SERVER) && $_SERVER[$optionKey] !== null && $_SERVER[$optionKey] !== '') {
|
||||
$output->writeln("[init:worker] transport: $option // $_SERVER[$optionKey] // env var");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getOptionValue(string $option, InputInterface $input): int
|
||||
{
|
||||
if ($input->getOption($option) !== false) {
|
||||
return $input->getOption($option) ?? 1;
|
||||
}
|
||||
|
||||
$optionKey = 'WORKER_' . strtoupper($option);
|
||||
return $_SERVER[$optionKey];
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ final class IndexController extends AbstractController
|
||||
#[Route('/test')]
|
||||
public function monitorTvShow(MonitorTvShowHandler $handler): Response
|
||||
{
|
||||
// $handler->handle(new MonitorTvShowCommand(82));
|
||||
throw new \Exception('Test');
|
||||
return $this->render('index/test.html.twig', []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,37 @@ class MediaFiles
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $tvshowTitle
|
||||
* @return array<SplFileInfo>|false
|
||||
*/
|
||||
public function tvshowExists(string $tvshowTitle): Map|false
|
||||
{
|
||||
$existingEpisodes = $this->getEpisodes($tvshowTitle, false);
|
||||
|
||||
if ($existingEpisodes->isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$episodes = new Map;
|
||||
/** @var SplFileInfo $episode */
|
||||
foreach ($existingEpisodes as $episode) {
|
||||
$ptn = (object) (new PTN())->parse($episode->getFilename());
|
||||
|
||||
if (!property_exists($ptn, 'season') || !property_exists($ptn, 'episode')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$episodes->push($episode);
|
||||
}
|
||||
|
||||
if ($episodes->count() > 0) {
|
||||
return $episodes;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function movieExists(string $title)
|
||||
{
|
||||
$filepath = $this->moviesPath . DIRECTORY_SEPARATOR . $title;
|
||||
|
||||
@@ -33,7 +33,6 @@ class Paginator
|
||||
public function paginate($query, int $page = 1, int $limit = 5): Paginator
|
||||
{
|
||||
$paginator = new OrmPaginator($query);
|
||||
|
||||
$paginator
|
||||
->getQuery()
|
||||
->setFirstResult($limit * ($page - 1))
|
||||
|
||||
141
src/Discover/Framework/Controller/WebController.php
Normal file
141
src/Discover/Framework/Controller/WebController.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Discover\Framework\Controller;
|
||||
|
||||
use App\Base\Enum\MediaType;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use App\Tmdb\TmdbMovieGenre;
|
||||
use App\Tmdb\TmdbTvShowGenre;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/discover')]
|
||||
class WebController extends AbstractController
|
||||
{
|
||||
#[Route('/', name: 'app.discover')]
|
||||
public function index(TmdbClient $tmdb)
|
||||
{
|
||||
$movies = $tmdb->popularMovies(18);
|
||||
$tvshows = $tmdb->popularTvShows(18);
|
||||
|
||||
return $this->render('discover/index.html.twig', [
|
||||
'movies' => $movies,
|
||||
'shows' => $tvshows,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{mediaType}/{genreId?}', name: 'app.discover.browse')]
|
||||
public function browse(string $mediaType, ?string $genreId, TmdbClient $tmdb)
|
||||
{
|
||||
if (null === $genreId) {
|
||||
if (MediaType::tryFrom($mediaType) === null) {
|
||||
return new Response(status: 404);
|
||||
}
|
||||
return $this->render('discover/browse.html.twig', [
|
||||
'genres' => self::getGenres($mediaType),
|
||||
'media_type' => $mediaType,
|
||||
]);
|
||||
}
|
||||
|
||||
if (MediaType::tryFrom($mediaType) === null) {
|
||||
return new Response(status: 404);
|
||||
}
|
||||
|
||||
if (TmdbMovieGenre::tryFrom($genreId) === null &&
|
||||
TmdbTvShowGenre::tryFrom($genreId) === null
|
||||
) {
|
||||
return new Response(status: 404);
|
||||
}
|
||||
|
||||
$results = match ($mediaType) {
|
||||
MediaType::Movie->value => $tmdb->discoverMovies(
|
||||
[TmdbMovieGenre::from($genreId)->value]
|
||||
),
|
||||
MediaType::TvShow->value => $tmdb->discoverTvShows(
|
||||
[TmdbTvShowGenre::from($genreId)->value]
|
||||
),
|
||||
};
|
||||
|
||||
return $this->render('discover/browse_genre.html.twig', [
|
||||
'media' => $results,
|
||||
'genre' => TmdbMovieGenre::from($genreId)->name,
|
||||
'genre_id' => $genreId,
|
||||
'media_type' => $mediaType,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{mediaType}/{genreId}', name: 'app.discover.browse_genre')]
|
||||
public function browseGenre(string $mediaType, string $genreId, TmdbClient $tmdb)
|
||||
{
|
||||
if (MediaType::tryFrom($mediaType) === null) {
|
||||
return new Response(status: 404);
|
||||
}
|
||||
|
||||
if (TmdbMovieGenre::tryFrom($genreId) === null &&
|
||||
TmdbTvShowGenre::tryFrom($genreId) === null
|
||||
) {
|
||||
return new Response(status: 404);
|
||||
}
|
||||
|
||||
$results = match ($mediaType) {
|
||||
MediaType::Movie->value => $tmdb->discoverMovies(
|
||||
[TmdbMovieGenre::from($genreId)->value]
|
||||
),
|
||||
MediaType::TvShow->value => $tmdb->discoverTvShows(
|
||||
[TmdbTvShowGenre::from($genreId)->value]
|
||||
),
|
||||
};
|
||||
|
||||
return $this->render('discover/browse.html.twig', [
|
||||
'media' => $results,
|
||||
'media_type' => $mediaType,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function getGenres(string $mediaType): array
|
||||
{
|
||||
return match ($mediaType) {
|
||||
MediaType::Movie->value => [
|
||||
'Action' => TmdbMovieGenre::Action->value,
|
||||
'Adventure' => TmdbMovieGenre::Adventure->value,
|
||||
'Animation' => TmdbMovieGenre::Animation->value,
|
||||
'Comedy' => TmdbMovieGenre::Comedy->value,
|
||||
'Crime' => TmdbMovieGenre::Crime->value,
|
||||
'Documentary' => TmdbMovieGenre::Documentary->value,
|
||||
'Drama' => TmdbMovieGenre::Drama->value,
|
||||
'Family' => TmdbMovieGenre::Family->value,
|
||||
'Fantasy' => TmdbMovieGenre::Fantasy->value,
|
||||
'History' => TmdbMovieGenre::History->value,
|
||||
'Horror' => TmdbMovieGenre::Horror->value,
|
||||
'Music' => TmdbMovieGenre::Music->value,
|
||||
'Mystery' => TmdbMovieGenre::Mystery->value,
|
||||
'Romance' => TmdbMovieGenre::Romance->value,
|
||||
'Science Fiction' => TmdbMovieGenre::ScienceFiction->value,
|
||||
'TV Movie' => TmdbMovieGenre::TvMovie->value,
|
||||
'Thriller' => TmdbMovieGenre::Thriller->value,
|
||||
'War' => TmdbMovieGenre::War->value,
|
||||
'Western' => TmdbMovieGenre::Western->value,
|
||||
],
|
||||
MediaType::TvShow->value => [
|
||||
'Action & Adventure' => TmdbTvShowGenre::ActionAndAdventure->value,
|
||||
'Animation' => TmdbTvShowGenre::Animation->value,
|
||||
'Comedy' => TmdbTvShowGenre::Comedy->value,
|
||||
'Crime' => TmdbTvShowGenre::Crime->value,
|
||||
'Documentary' => TmdbTvShowGenre::Documentary->value,
|
||||
'Drama' => TmdbTvShowGenre::Drama->value,
|
||||
'Family' => TmdbTvShowGenre::Family->value,
|
||||
'Kids' => TmdbTvShowGenre::Kids->value,
|
||||
'Mystery' => TmdbTvShowGenre::Mystery->value,
|
||||
'News' => TmdbTvShowGenre::News->value,
|
||||
'Reality' => TmdbTvShowGenre::Reality->value,
|
||||
'Sci-Fi & Fantasy' => TmdbTvShowGenre::SciFiAndFantasy->value,
|
||||
'Soap' => TmdbTvShowGenre::Soap->value,
|
||||
'Talk' => TmdbTvShowGenre::Talk->value,
|
||||
'War & Politics' => TmdbTvShowGenre::WarAndPolitics->value,
|
||||
'Western' => TmdbTvShowGenre::Western->value,
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +46,14 @@ readonly class DownloadSeasonHandler implements HandlerInterface
|
||||
|
||||
$downloadCommands = [];
|
||||
foreach ($episodesInSeason as $episode) {
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']);
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode->episodeNumber);
|
||||
|
||||
$results = $this->getTvShowOptionsHandler->handle(
|
||||
new GetTvShowOptionsCommand(
|
||||
$series->tmdbId,
|
||||
$command->imdbId,
|
||||
$command->season,
|
||||
$episode['episode_number']
|
||||
$episode->episodeNumber
|
||||
)
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
|
||||
|
||||
if (null !== $result) {
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result');
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode['episode_number']);
|
||||
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode->episodeNumber);
|
||||
$downloadCommand = new DownloadMediaCommand(
|
||||
$result->url,
|
||||
$series->title,
|
||||
@@ -99,10 +99,10 @@ readonly class DownloadSeasonHandler implements HandlerInterface
|
||||
->filter(fn ($episode) =>
|
||||
property_exists($episode, 'episode')
|
||||
&& property_exists($episode, 'season')
|
||||
&& null !== $episode->episode
|
||||
&& null !== $episode->episodeNumber
|
||||
&& null !== $episode->season
|
||||
)
|
||||
->rekey(fn($episode) => $episode->episode);
|
||||
->rekey(fn($episode) => $episode->episodeNumber);
|
||||
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
59
src/Download/Action/Handler/MonitorHandlerTrait.php
Normal file
59
src/Download/Action/Handler/MonitorHandlerTrait.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Handler;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Tmdb\Dto\TmdbEpisodeDto;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use Carbon\Carbon;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
trait MonitorHandlerTrait
|
||||
{
|
||||
private MonitorRepository $monitorRepository;
|
||||
private LoggerInterface $logger;
|
||||
private TmdbClient $tmdb;
|
||||
|
||||
private function episodeReleasedAfterMonitorCreated(
|
||||
string|DateTimeImmutable $monitorStartDate,
|
||||
TmdbEpisodeDto $episodeInShow
|
||||
): bool {
|
||||
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
|
||||
$episodeAirDate = Carbon::parse($episodeInShow->airDate);
|
||||
return $episodeAirDate >= $monitorStartDate;
|
||||
}
|
||||
|
||||
private function episodeExists(TmdbEpisodeDto $episodeInShow, Map $downloadedEpisodes): bool
|
||||
{
|
||||
return $downloadedEpisodes->filter(
|
||||
fn(object $episode) => $episode->episode === $episodeInShow->episodeNumber
|
||||
&& $episode->season === $episodeInShow->seasonNumber
|
||||
)->count() > 0;
|
||||
}
|
||||
|
||||
private function monitorExists(Monitor $monitor, TmdbEpisodeDto $episode): bool
|
||||
{
|
||||
return $this->monitorRepository->findOneBy([
|
||||
'imdbId' => $monitor->getImdbId(),
|
||||
'title' => $monitor->getTitle(),
|
||||
'monitorType' => 'tvepisode',
|
||||
'season' => $episode->seasonNumber,
|
||||
'episode' => $episode->episodeNumber,
|
||||
'status' => ['New', 'Active', 'In Progress']
|
||||
]) !== null;
|
||||
}
|
||||
|
||||
private function refreshData(Monitor $monitor)
|
||||
{
|
||||
if (null === $monitor->getPoster()) {
|
||||
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
|
||||
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
|
||||
if (null !== $poster && "" !== $poster) {
|
||||
$monitor->setPoster($poster);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
20
src/Library/Action/Command/GetMediaFromLibraryCommand.php
Normal file
20
src/Library/Action/Command/GetMediaFromLibraryCommand.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Command;
|
||||
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<GetMediaFromLibraryCommand>
|
||||
*/
|
||||
class GetMediaFromLibraryCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $userId = null,
|
||||
public ?string $mediaType = null,
|
||||
public ?string $imdbId = null,
|
||||
public ?string $title = null,
|
||||
public ?string $season = null,
|
||||
public ?string $episode = null,
|
||||
) {}
|
||||
}
|
||||
169
src/Library/Action/Handler/GetMediaInfoFromLibraryHandler.php
Normal file
169
src/Library/Action/Handler/GetMediaInfoFromLibraryHandler.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Handler;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Base\Enum\MediaType;
|
||||
use App\Base\Service\MediaFiles;
|
||||
use App\Base\Util\PTN;
|
||||
use App\Library\Action\Command\GetMediaFromLibraryCommand;
|
||||
use App\Library\Action\Result\GetMediaFromLibraryResult;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface as C;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface as R;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* @implements HandlerInterface<GetMediaFromLibraryCommand,GetMediaFromLibraryResult>
|
||||
*/
|
||||
class GetMediaInfoFromLibraryHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TmdbClient $tmdb,
|
||||
private readonly MediaFiles $mediaFiles,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly MonitorRepository $monitorRepository,
|
||||
) {}
|
||||
|
||||
public function handle(C $command): R
|
||||
{
|
||||
$result = new GetMediaFromLibraryResult();
|
||||
$tmdbResult = $this->fetchTmdbData($command->imdbId, $command->mediaType);
|
||||
|
||||
if (null === $tmdbResult) {
|
||||
$this->logger->warning('[GetMediaInfoFromLibraryHandler] TMDb result was not found, this may lead to issues in the rest of the library search', (array) $command);
|
||||
}
|
||||
|
||||
$this->setResultExists($tmdbResult->mediaType, $tmdbResult->title, $result);
|
||||
if ($result->notExists()) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$this->parseFromTmdbResult($tmdbResult, $result);
|
||||
|
||||
if ($command->mediaType === MediaType::TvShow->value) {
|
||||
$this->setEpisodes($tmdbResult, $result);
|
||||
$this->setSeasons($tmdbResult, $result);
|
||||
$this->setMonitors($command->userId, $command->imdbId, $result);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function fetchTmdbData(string $imdbId, string $mediaType): ?TmdbResult
|
||||
{
|
||||
return match($mediaType) {
|
||||
MediaType::Movie->value => $this->tmdb->movieDetails($imdbId),
|
||||
MediaType::TvShow->value => $this->tmdb->tvShowDetails($imdbId),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function setResultExists(string $mediaType, string $title, GetMediaFromLibraryResult $result): void
|
||||
{
|
||||
$fsResult = match($mediaType) {
|
||||
MediaType::Movie->value => $this->mediaFiles->movieExists($title),
|
||||
MediaType::TvShow->value => $this->mediaFiles->tvShowExists($title),
|
||||
default => false,
|
||||
};
|
||||
if (false === $fsResult) {
|
||||
$result->setExists(false);
|
||||
} else {
|
||||
$result->setExists(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function parseFromTmdbResult(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
|
||||
{
|
||||
$result->setTitle($tmdbResult->title);
|
||||
$result->setMediaType($tmdbResult->mediaType);
|
||||
$result->setImdbId($tmdbResult->imdbId);
|
||||
}
|
||||
|
||||
public function setEpisodes(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
|
||||
{
|
||||
$existingEpisodeFiles = $this->mediaFiles->tvshowExists($tmdbResult->title);
|
||||
|
||||
$existingEpisodeMap = [];
|
||||
foreach ($existingEpisodeFiles as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
$ptn = (object) new PTN()->parse($file->getBasename());
|
||||
|
||||
if (!array_key_exists($ptn->season, $existingEpisodeMap)) {
|
||||
$existingEpisodeMap[$ptn->season] = [];
|
||||
}
|
||||
|
||||
if (!in_array($ptn->episode, $existingEpisodeMap[$ptn->season])) {
|
||||
$existingEpisodeMap[$ptn->season][] = $ptn->episode;
|
||||
}
|
||||
}
|
||||
|
||||
$existingEpisodes = [];
|
||||
$missingEpisodes = [];
|
||||
foreach ($tmdbResult->episodes as $season => $episodes) {
|
||||
foreach ($episodes as $episode) {
|
||||
if (array_key_exists($season, $existingEpisodeMap)) {
|
||||
if (in_array($episode->episodeNumber, $existingEpisodeMap[$season])) {
|
||||
$existingEpisodes[] = $episode;
|
||||
} else {
|
||||
$missingEpisodes[] = $episode;
|
||||
}
|
||||
} else {
|
||||
$missingEpisodes[] = $episode;
|
||||
}
|
||||
}
|
||||
}
|
||||
$result->setEpisodes($existingEpisodes);
|
||||
$result->setMissingEpisodes($missingEpisodes);
|
||||
}
|
||||
|
||||
public function setSeasons(TmdbResult $tmdbResult, GetMediaFromLibraryResult $result): void
|
||||
{
|
||||
$existingEpisodeFiles = $this->mediaFiles->tvshowExists($tmdbResult->title);
|
||||
|
||||
$existingEpisodeMap = [];
|
||||
foreach ($existingEpisodeFiles as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
$ptn = (object) new PTN()->parse($file->getBasename());
|
||||
$existingEpisodeMap[$ptn->season][] = $ptn->episode;
|
||||
}
|
||||
|
||||
$existingFullSeasons = [];
|
||||
$existingPartialSeasons = [];
|
||||
$missingSeasons = [];
|
||||
foreach ($existingEpisodeMap as $season => $episodes) {
|
||||
if (count($tmdbResult->episodes[$season]) === count($episodes)) {
|
||||
$existingFullSeasons[] = $season;
|
||||
} elseif (count($episodes) > 0) {
|
||||
$existingPartialSeasons[] = $season;
|
||||
}
|
||||
}
|
||||
|
||||
$seasons = array_keys($tmdbResult->episodes);
|
||||
foreach ($seasons as $season) {
|
||||
if (!in_array($season, $existingFullSeasons) && !in_array($season, $existingPartialSeasons)) {
|
||||
$missingSeasons[] = $season;
|
||||
}
|
||||
}
|
||||
|
||||
$result->setSeasons($existingFullSeasons);
|
||||
$result->setPartialSeasons($existingPartialSeasons);
|
||||
$result->setMissingSeasons($missingSeasons);
|
||||
}
|
||||
|
||||
public function setMonitors(int $userId, string $imdbId, GetMediaFromLibraryResult $result)
|
||||
{
|
||||
$result->setMonitorCount(
|
||||
$this->monitorRepository->countUserChildrenByParentId($userId, $imdbId)
|
||||
);
|
||||
$result->setActiveMonitorCount(
|
||||
$this->monitorRepository->countUserActiveChildrenByParentId($userId, $imdbId)
|
||||
);
|
||||
$result->setCompleteMonitorCount(
|
||||
$this->monitorRepository->countUserCompleteChildrenByParentId($userId, $imdbId)
|
||||
);
|
||||
}
|
||||
}
|
||||
39
src/Library/Action/Input/GetMediaInfoFromLibraryInput.php
Normal file
39
src/Library/Action/Input/GetMediaInfoFromLibraryInput.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Input;
|
||||
|
||||
use App\Library\Action\Command\GetMediaFromLibraryCommand;
|
||||
use OneToMany\RichBundle\Attribute\SourceRequest;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface as C;
|
||||
use OneToMany\RichBundle\Contract\InputInterface;
|
||||
|
||||
/**
|
||||
* @implements InputInterface<GetMediaInfoFromLibraryInput, GetMediaFromLibraryCommand>
|
||||
*/
|
||||
class GetMediaInfoFromLibraryInput implements InputInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[SourceRequest('imdbId', nullify: true)]
|
||||
public ?string $imdbId = null,
|
||||
#[SourceRequest('title', nullify: true)]
|
||||
public ?string $title = null,
|
||||
#[SourceRequest('season', nullify: true)]
|
||||
public ?string $season = null,
|
||||
#[SourceRequest('episode', nullify: true)]
|
||||
public ?string $episode = null,
|
||||
) {}
|
||||
|
||||
public function toCommand(): C
|
||||
{
|
||||
if (null === $this->imdbId && null === $this->title) {
|
||||
throw new \InvalidArgumentException('Either imdbId or title must be set', 400);
|
||||
}
|
||||
|
||||
return new GetMediaFromLibraryCommand(
|
||||
imdbId: $this->imdbId,
|
||||
title: $this->title,
|
||||
season: $this->season,
|
||||
episode: $this->episode,
|
||||
);
|
||||
}
|
||||
}
|
||||
171
src/Library/Action/Result/GetMediaFromLibraryResult.php
Normal file
171
src/Library/Action/Result/GetMediaFromLibraryResult.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Library\Action\Result;
|
||||
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
class GetMediaFromLibraryResult implements ResultInterface
|
||||
{
|
||||
private bool $exists;
|
||||
private ?string $title = null;
|
||||
private ?string $imdbId = null;
|
||||
private ?string $mediaType = null;
|
||||
private ?array $episodes = null;
|
||||
private ?array $missingEpisodes = null;
|
||||
private ?array $seasons = null;
|
||||
private ?array $partialSeasons = null;
|
||||
private ?array $missingSeasons = null;
|
||||
private ?int $monitorCount = null; // Monitor Repo
|
||||
private ?int $activeMonitorCount = null; // Monitor Repo
|
||||
private ?int $completeMonitorCount = null; // Monitor Repo
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return $this->exists;
|
||||
}
|
||||
|
||||
public function notExists(): bool
|
||||
{
|
||||
return !$this->exists;
|
||||
}
|
||||
|
||||
public function setExists(bool $exists): void
|
||||
{
|
||||
$this->exists = $exists;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function getImdbId(): ?string
|
||||
{
|
||||
return $this->imdbId;
|
||||
}
|
||||
|
||||
public function setImdbId(?string $imdbId): void
|
||||
{
|
||||
$this->imdbId = $imdbId;
|
||||
}
|
||||
|
||||
public function getMediaType(): ?string
|
||||
{
|
||||
return $this->mediaType;
|
||||
}
|
||||
|
||||
public function setMediaType(?string $mediaType): void
|
||||
{
|
||||
$this->mediaType = $mediaType;
|
||||
}
|
||||
|
||||
public function getEpisodes(): ?array
|
||||
{
|
||||
return $this->episodes;
|
||||
}
|
||||
|
||||
public function setEpisodes(?array $episodes): void
|
||||
{
|
||||
$this->episodes = $episodes;
|
||||
}
|
||||
|
||||
public function getEpisodeCount(): ?int
|
||||
{
|
||||
return count($this->episodes);
|
||||
}
|
||||
|
||||
public function getMissingEpisodes(): ?array
|
||||
{
|
||||
return $this->missingEpisodes;
|
||||
}
|
||||
|
||||
public function setMissingEpisodes(?array $missingEpisodes): void
|
||||
{
|
||||
$this->missingEpisodes = $missingEpisodes;
|
||||
}
|
||||
|
||||
public function getMissingEpisodeCount(): ?int
|
||||
{
|
||||
return count($this->missingEpisodes);
|
||||
}
|
||||
|
||||
public function getSeasons(): ?array
|
||||
{
|
||||
return $this->seasons;
|
||||
}
|
||||
|
||||
public function setSeasons(?array $seasons): void
|
||||
{
|
||||
$this->seasons = $seasons;
|
||||
}
|
||||
|
||||
public function getSeasonCount(): ?int
|
||||
{
|
||||
return count($this->seasons);
|
||||
}
|
||||
|
||||
public function getPartialSeasons(): ?array
|
||||
{
|
||||
return $this->partialSeasons;
|
||||
}
|
||||
|
||||
public function setPartialSeasons(?array $partialSeasons): void
|
||||
{
|
||||
$this->partialSeasons = $partialSeasons;
|
||||
}
|
||||
|
||||
public function getPartialSeasonCount(): ?int
|
||||
{
|
||||
return count($this->partialSeasons);
|
||||
}
|
||||
|
||||
public function getMissingSeasons(): ?array
|
||||
{
|
||||
return $this->missingSeasons;
|
||||
}
|
||||
|
||||
public function setMissingSeasons(?array $missingSeasons): void
|
||||
{
|
||||
$this->missingSeasons = $missingSeasons;
|
||||
}
|
||||
|
||||
public function getMissingSeasonCount(): ?int
|
||||
{
|
||||
return count($this->missingSeasons);
|
||||
}
|
||||
|
||||
public function getMonitorCount(): ?int
|
||||
{
|
||||
return $this->monitorCount;
|
||||
}
|
||||
|
||||
public function setMonitorCount(?int $monitorCount): void
|
||||
{
|
||||
$this->monitorCount = $monitorCount;
|
||||
}
|
||||
|
||||
public function getActiveMonitorCount(): ?int
|
||||
{
|
||||
return $this->activeMonitorCount;
|
||||
}
|
||||
|
||||
public function setActiveMonitorCount(?int $activeMonitorCount): void
|
||||
{
|
||||
$this->activeMonitorCount = $activeMonitorCount;
|
||||
}
|
||||
|
||||
public function getCompleteMonitorCount(): ?int
|
||||
{
|
||||
return $this->completeMonitorCount;
|
||||
}
|
||||
|
||||
public function setCompleteMonitorCount(?int $completeMonitorCount): void
|
||||
{
|
||||
$this->completeMonitorCount = $completeMonitorCount;
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,38 @@
|
||||
|
||||
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\Tmdb\TmdbClient;
|
||||
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,
|
||||
private TmdbClient $tmdb,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$user = $this->userRepository->find($command->userId);
|
||||
$poster = $this->getPoster($command->imdbId);
|
||||
|
||||
$monitor = (new Monitor())
|
||||
->setUser($user)
|
||||
->setPoster($poster)
|
||||
->setTmdbId($command->tmdbId)
|
||||
->setImdbId($command->imdbId)
|
||||
->setTitle($command->title)
|
||||
@@ -35,6 +44,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();
|
||||
|
||||
@@ -45,4 +61,10 @@ readonly class AddMonitorHandler implements HandlerInterface
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getPoster(string $imdbId): ?string
|
||||
{
|
||||
$data = $this->tmdb->tvShowDetails($imdbId);
|
||||
return $data->poster;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +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\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Monitor\MonitorEvents;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
@@ -41,14 +44,33 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
try {
|
||||
$monitor = $this->monitorRepository->find($command->movieMonitorId);
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
|
||||
$this->refreshData($monitor);
|
||||
|
||||
$episodeData = $this->tmdb->tvEpisodeDetails($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 +127,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: [
|
||||
@@ -118,4 +153,15 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function refreshData(Monitor $monitor)
|
||||
{
|
||||
if (null === $monitor->getPoster()) {
|
||||
$this->logger->info('> [MonitorTvEpisodeHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
|
||||
$poster = $monitor->getParent()->getPoster();
|
||||
if (null !== $poster && "" !== $poster) {
|
||||
$monitor->setPoster($poster);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,89 +30,89 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
private MediaFiles $mediaFiles,
|
||||
private LoggerInterface $logger,
|
||||
private TmdbClient $tmdb,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
|
||||
$monitor = $this->monitorRepository->find($command->monitorId);
|
||||
$this->refreshData($monitor);
|
||||
|
||||
// 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->tvshowDetails($monitor->getImdbId())->episodes
|
||||
)->flat(1);
|
||||
)->flat(1)
|
||||
->filter(fn(TmdbEpisodeDto $episode) => $episode->seasonNumber >= $monitor->getSeason())
|
||||
->values();
|
||||
|
||||
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle());
|
||||
|
||||
$episodeMonitors = [];
|
||||
if ($downloadedEpisodes->count() !== $episodesInShow->count()) {
|
||||
// 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');
|
||||
// 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.
|
||||
@@ -130,8 +130,10 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
);
|
||||
}
|
||||
|
||||
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, TmdbEpisodeDto $episodeInShow): bool
|
||||
{
|
||||
private function episodeReleasedAfterMonitorCreated(
|
||||
string|DateTimeImmutable $monitorStartDate,
|
||||
TmdbEpisodeDto $episodeInShow
|
||||
): bool {
|
||||
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
|
||||
$episodeAirDate = Carbon::parse($episodeInShow->airDate);
|
||||
return $episodeAirDate >= $monitorStartDate;
|
||||
@@ -140,8 +142,8 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
private function episodeExists(TmdbEpisodeDto $episodeInShow, Map $downloadedEpisodes): bool
|
||||
{
|
||||
return $downloadedEpisodes->filter(
|
||||
fn (object $episode) => $episode->episode === $episodeInShow->episodeNumber
|
||||
&& $episode->season === $episodeInShow->seasonNumber
|
||||
fn(object $episode) => $episode->episode === $episodeInShow->episodeNumber
|
||||
&& $episode->season === $episodeInShow->seasonNumber
|
||||
)->count() > 0;
|
||||
}
|
||||
|
||||
@@ -156,4 +158,15 @@ readonly class MonitorTvShowHandler implements HandlerInterface
|
||||
'status' => ['New', 'Active', 'In Progress']
|
||||
]) !== null;
|
||||
}
|
||||
|
||||
private function refreshData(Monitor $monitor)
|
||||
{
|
||||
if (null === $monitor->getPoster()) {
|
||||
$this->logger->info('> [MonitorTvShowHandler] Refreshing poster for "' . $monitor->getTitle() . '"');
|
||||
$poster = $this->tmdb->tvshowDetails($monitor->getImdbId())->poster;
|
||||
if (null !== $poster && "" !== $poster) {
|
||||
$monitor->setPoster($poster);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ class ApiController extends AbstractController
|
||||
'allDay' => true,
|
||||
'backgroundColor' => $eventColors[$monitor->getImdbId()],
|
||||
'borderColor' => $eventColors[$monitor->getImdbId()],
|
||||
'attachment' => $monitor->getPoster(),
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Monitor\Framework\Controller;
|
||||
|
||||
use Aimeos\Map;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\User\Framework\Entity\User;
|
||||
use Spatie\IcalendarGenerator\Components\Calendar;
|
||||
@@ -27,10 +28,14 @@ class CalendarController extends AbstractController
|
||||
->refreshInterval(10);
|
||||
|
||||
$monitors = $monitorRepository->whereAirDateNotNull();
|
||||
$calendar->event(Map::from($monitors)->map(function ($monitor) {
|
||||
return new Event($monitor->getTitle())
|
||||
$calendar->event(Map::from($monitors)->map(function (Monitor $monitor) {
|
||||
$event = new Event($monitor->getTitle())
|
||||
->startsAt($monitor->getAirDate())
|
||||
->fullDay();
|
||||
if (null !== $monitor->getPoster()) {
|
||||
$event->attachment($monitor->getPoster());
|
||||
}
|
||||
return $event;
|
||||
})->toArray());
|
||||
|
||||
return new Response($calendar->get(), 200, [
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
namespace App\Monitor\Framework\Controller;
|
||||
|
||||
use App\Download\Action\Input\DeleteDownloadInput;
|
||||
use App\Library\Action\Command\GetMediaFromLibraryCommand;
|
||||
use App\Library\Action\Handler\GetMediaInfoFromLibraryHandler;
|
||||
use App\Monitor\Action\Handler\AddMonitorHandler;
|
||||
use App\Monitor\Action\Handler\DeleteMonitorHandler;
|
||||
use App\Monitor\Action\Input\AddMonitorInput;
|
||||
use App\Monitor\Action\Input\DeleteMonitorInput;
|
||||
use App\Monitor\Framework\Entity\Monitor;
|
||||
use App\Monitor\Framework\Repository\MonitorRepository;
|
||||
use App\Search\Action\Command\GetMediaInfoCommand;
|
||||
use App\Search\Action\Handler\GetMediaInfoHandler;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -23,7 +29,7 @@ class WebController extends AbstractController
|
||||
private readonly Environment $renderer,
|
||||
) {}
|
||||
|
||||
#[Route('/monitors', name: 'app_monitors', methods: ['GET'])]
|
||||
#[Route('/monitors', name: 'app.monitors', methods: ['GET'])]
|
||||
public function addMonitor()
|
||||
{
|
||||
return $this->render('monitor/index.html.twig');
|
||||
@@ -34,4 +40,29 @@ class WebController extends AbstractController
|
||||
{
|
||||
return $this->render('monitor/upcoming-episodes.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/monitors/{id}', name: 'app.monitor.view', methods: ['GET'])]
|
||||
public function viewMonitor(Monitor $monitor, GetMediaInfoHandler $getMediaInfoHandler, GetMediaInfoFromLibraryHandler $handler)
|
||||
{
|
||||
$media = $getMediaInfoHandler->handle(
|
||||
new GetMediaInfoCommand(
|
||||
imdbId: $monitor->getImdbId(),
|
||||
mediaType: 'tvshows',
|
||||
)
|
||||
);
|
||||
$libraryResult = $handler->handle(
|
||||
new GetMediaFromLibraryCommand(
|
||||
$this->getUser()->getId(),
|
||||
$media->media->mediaType,
|
||||
$media->media->imdbId,
|
||||
$media->media->title,
|
||||
)
|
||||
);
|
||||
|
||||
return $this->render('monitor/view.html.twig', [
|
||||
'monitor' => $monitor,
|
||||
'results' => $media,
|
||||
'library' => $libraryResult
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ class Monitor
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?int $searchCount = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?string $poster = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private bool $onlyFuture = true;
|
||||
|
||||
@@ -230,6 +233,17 @@ class Monitor
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPoster(): ?string
|
||||
{
|
||||
return $this->poster;
|
||||
}
|
||||
|
||||
public function setPoster(?string $poster): ?self
|
||||
{
|
||||
$this->poster = $poster;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParent(): ?self
|
||||
{
|
||||
return $this->parent;
|
||||
|
||||
@@ -41,4 +41,83 @@ class MonitorRepository extends ServiceEntityRepository
|
||||
->getQuery();
|
||||
return $query->getResult();
|
||||
}
|
||||
|
||||
public function getActiveUserMonitors()
|
||||
{
|
||||
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
|
||||
->andWhere('m.status IN (:statuses)')
|
||||
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
|
||||
->andWhere('m.parent IS NULL')
|
||||
->setParameter('statuses', ['New', 'In Progress', 'Active'])
|
||||
->setParameter('term', '%'.$this->term.'%')
|
||||
->orderBy('m.id', 'DESC')
|
||||
->getQuery()
|
||||
);
|
||||
}
|
||||
|
||||
public function getChildMonitorsByParentId(int $parentId)
|
||||
{
|
||||
return $this->asPaginator(
|
||||
$this->monitorRepository->createQueryBuilder('m')
|
||||
->andWhere("m.parent = :parentId")
|
||||
->setParameter('parentId', $parentId)
|
||||
->orderBy('m.id', 'DESC')
|
||||
->getQuery()
|
||||
);
|
||||
}
|
||||
|
||||
public function getCompleteUserMonitors()
|
||||
{
|
||||
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
|
||||
->andWhere('m.status = :status')
|
||||
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
|
||||
->setParameter('status', 'Complete')
|
||||
->setParameter('term', '%'.$this->term.'%')
|
||||
->orderBy('m.id', 'DESC')
|
||||
->getQuery()
|
||||
);
|
||||
}
|
||||
|
||||
public function countUserChildrenByParentId(int $userId, string $imdbId): ?int
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
->andWhere('m.user = :user')
|
||||
->andWhere('m.imdbId = :imdbId')
|
||||
->setParameter('user', $userId)
|
||||
->setParameter('imdbId', $imdbId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function countUserActiveChildrenByParentId(int $userId, string $imdbId): ?int
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
->andWhere('m.user = :user')
|
||||
->andWhere('m.imdbId = :imdbId')
|
||||
->andWhere('m.status IN (:statuses)')
|
||||
->setParameter('user', $userId)
|
||||
->setParameter('statuses', ['Active', 'New', 'In Progress'])
|
||||
->setParameter('imdbId', $imdbId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function countUserCompleteChildrenByParentId(int $userId, string $imdbId): ?int
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
->andWhere('m.user = :user')
|
||||
->andWhere('m.imdbId = :imdbId')
|
||||
->andWhere('m.status IN (:statuses)')
|
||||
->setParameter('user', $userId)
|
||||
->setParameter('statuses', ['Complete'])
|
||||
->setParameter('imdbId', $imdbId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@ class GetMediaInfoHandler implements HandlerInterface
|
||||
$handlers = [
|
||||
MediaType::Movie->value => 'getMovieDetails',
|
||||
MediaType::TvShow->value => 'getTvshowDetails',
|
||||
MediaType::TvEpisode->value => 'getTvshowDetails',
|
||||
];
|
||||
$handler = $handlers[$command->mediaType];
|
||||
$media = $this->$handler($command);
|
||||
|
||||
22
src/Tmdb/Dto/WatchProviderDto.php
Normal file
22
src/Tmdb/Dto/WatchProviderDto.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tmdb\Dto;
|
||||
|
||||
use Symfony\Component\Serializer\Attribute\SerializedPath;
|
||||
|
||||
class WatchProviderDto
|
||||
{
|
||||
const BASE_LOGO_PATH = 'https://image.tmdb.org/t/p/w185';
|
||||
|
||||
#[SerializedPath('[provider_id]')]
|
||||
public int $id;
|
||||
#[SerializedPath('[display_priority]')]
|
||||
public int $displayPriority;
|
||||
#[SerializedPath('[provider_name]')]
|
||||
public string $name;
|
||||
#[SerializedPath('[logo_path]')]
|
||||
public string $logo {
|
||||
set(string $value) => self::BASE_LOGO_PATH . $value;
|
||||
}
|
||||
public string $url;
|
||||
}
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
namespace App\Tmdb\Framework\Controller;
|
||||
|
||||
use App\Base\Enum\MediaType;
|
||||
use App\Base\Util\ImdbMatcher;
|
||||
use App\Library\Action\Result\LibrarySearchResult;
|
||||
use App\Tmdb\TmdbClient;
|
||||
use App\Tmdb\TmdbMovieGenre;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use App\Tmdb\TmdbTvShowGenre;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\UX\Turbo\TurboBundle;
|
||||
|
||||
class ApiController extends AbstractController
|
||||
{
|
||||
@@ -47,4 +52,50 @@ class ApiController extends AbstractController
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/tmdb/watch-providers/{mediaType}/{tmdbId}', name: 'api.tmdb.watch_providers', methods: ['GET'])]
|
||||
public function watchProviders(string $mediaType, string $tmdbId, Request $request, TmdbClient $tmdb)
|
||||
{
|
||||
$result = $tmdb->watchProviders($tmdbId, $mediaType);
|
||||
if ($request->headers->get('Turbo-Frame')) {
|
||||
return $this->sendFragmentResponse(['providers' => $result], $request);
|
||||
}
|
||||
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
#[Route('/api/tmdb/genre/{mediaType}/{genreId}', name: 'api.tmdb.genre', methods: ['GET'])]
|
||||
public function genreResults(string $mediaType, string $genreId, Request $request, TmdbClient $tmdb)
|
||||
{
|
||||
$genre = TmdbMovieGenre::from($genreId);
|
||||
$results['media_type'] = $mediaType;
|
||||
$results['genre'] = $genre->name;
|
||||
$results['genre_id'] = $genre->value;
|
||||
$results['media'] = match($mediaType) {
|
||||
MediaType::Movie->value => $tmdb->discoverMovies(
|
||||
[TmdbMovieGenre::from($genre->value)],
|
||||
),
|
||||
MediaType::TvShow->value => $tmdb->discoverTvShows(
|
||||
[TmdbTvShowGenre::from($genreId)]
|
||||
)
|
||||
};
|
||||
if ($request->headers->get('Turbo-Frame')) {
|
||||
return $this->sendFragmentResponse(['result' => $results], $request);
|
||||
}
|
||||
|
||||
return $this->json($results);
|
||||
}
|
||||
|
||||
private function sendFragmentResponse(mixed $result, Request $request): Response
|
||||
{
|
||||
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
|
||||
return $this->renderBlock(
|
||||
'discover/fragments.html.twig',
|
||||
$request->query->get('block'),
|
||||
[
|
||||
'result' => $result,
|
||||
'target' => $request->query->get('target')
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Base\Enum\MediaType;
|
||||
use App\Tmdb\Dto\CastMemberDto;
|
||||
use App\Tmdb\Dto\CrewMemberDto;
|
||||
use App\Tmdb\Dto\GenreDto;
|
||||
use App\Tmdb\Dto\WatchProviderDto;
|
||||
use App\Tmdb\TmdbResult;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
@@ -28,6 +29,7 @@ class TmdbResultDenormalizer implements DenormalizerInterface
|
||||
$result->directors = $this->getDirectors($data);
|
||||
$result->producers = $this->getProducers($data);
|
||||
$result->creators = $this->getCreators($data);
|
||||
$result->watchProviders = $this->getWatchProviders($data);
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -87,6 +89,17 @@ class TmdbResultDenormalizer implements DenormalizerInterface
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getWatchProviders(array $data): ?array
|
||||
{
|
||||
if (!array_key_exists('watch/providers', $data)) {
|
||||
return null;
|
||||
}
|
||||
// ToDo: Make region configurable
|
||||
return Map::from($data['watch/providers']['results']['US']['flatrate'])
|
||||
->map(fn($item) => $this->normalizer->denormalize($item, WatchProviderDto::class))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function supportsDenormalization(
|
||||
mixed $data,
|
||||
string $type,
|
||||
|
||||
@@ -38,6 +38,10 @@ class TmdbTvShowResultDenormalizer extends TmdbResultDenormalizer implements Den
|
||||
$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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ use Aimeos\Map;
|
||||
use App\Base\Enum\MediaType;
|
||||
use App\Base\Util\ImdbMatcher;
|
||||
use App\Tmdb\Dto\TmdbEpisodeDto;
|
||||
use App\Tmdb\Dto\WatchProviderDto;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Tmdb\Api\Find;
|
||||
use Tmdb\Client;
|
||||
@@ -19,6 +21,7 @@ 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\DiscoverRepository;
|
||||
use Tmdb\Repository\MovieRepository;
|
||||
use Tmdb\Repository\SearchRepository;
|
||||
use Tmdb\Repository\TvEpisodeRepository;
|
||||
@@ -30,6 +33,7 @@ use Tmdb\Token\Api\BearerToken;
|
||||
class TmdbClient
|
||||
{
|
||||
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
|
||||
const APPEND_TO_RESPONSE = 'external_ids,credits';
|
||||
|
||||
protected Client $client;
|
||||
protected MovieRepository $movieRepository;
|
||||
@@ -38,6 +42,8 @@ class TmdbClient
|
||||
protected TvEpisodeRepository $tvEpisodeRepository;
|
||||
protected SearchRepository $searchRepository;
|
||||
|
||||
protected DiscoverRepository $discoverRepository;
|
||||
|
||||
protected array $mediaTypeMap = [
|
||||
MediaType::Movie->value => MediaType::Movie->value,
|
||||
MediaType::TvShow->value => MediaType::TvShow->value,
|
||||
@@ -48,11 +54,14 @@ class TmdbClient
|
||||
|
||||
protected $repos = [];
|
||||
|
||||
private ?string $originalLanguage = 'en';
|
||||
|
||||
public function __construct(
|
||||
private readonly SerializerInterface $serializer,
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
#[Autowire(env: 'TMDB_API')] string $apiKey,
|
||||
#[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
|
||||
) {
|
||||
$this->client = new Client(
|
||||
[
|
||||
@@ -74,7 +83,7 @@ class TmdbClient
|
||||
'hydration' => [
|
||||
'event_listener_handles_hydration' => false,
|
||||
'only_for_specified_models' => []
|
||||
]
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
@@ -107,11 +116,14 @@ class TmdbClient
|
||||
$this->tvSeasonRepository = new TvSeasonRepository($this->client);
|
||||
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
|
||||
$this->searchRepository = new SearchRepository($this->client);
|
||||
$this->discoverRepository = new DiscoverRepository($this->client);
|
||||
|
||||
$this->repos = [
|
||||
MediaType::Movie->value => $this->movieRepository,
|
||||
MediaType::TvShow->value => $this->tvRepository,
|
||||
MediaType::TvEpisode->value => $this->tvEpisodeRepository,
|
||||
];
|
||||
$this->originalLanguage = $originalLanguage;
|
||||
}
|
||||
|
||||
public function search(string $term): TmdbResult|Map
|
||||
@@ -129,11 +141,55 @@ class TmdbClient
|
||||
return $this->parseListOfResults($results);
|
||||
}
|
||||
|
||||
public function discoverMovies(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
|
||||
{
|
||||
if (!empty($genres) && $genres[0] instanceof TmdbMovieGenre) {
|
||||
$genres = array_map(fn ($genre) => $genre->value, $genres);
|
||||
}
|
||||
$results = $this->discoverRepository->getApi()->discoverMovies([
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'with_genres' => implode(',', $genres),
|
||||
'with_original_language' => $this->originalLanguage,
|
||||
'append_to_response' => static::APPEND_TO_RESPONSE,
|
||||
]);
|
||||
$results['results'] = Map::from($results['results'])->map(function ($result) {
|
||||
$result['media_type'] = MediaType::Movie->value;
|
||||
return $result;
|
||||
});
|
||||
|
||||
return $this->parseListOfResults(
|
||||
$results,
|
||||
);
|
||||
}
|
||||
|
||||
public function discoverTvshows(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
|
||||
{
|
||||
if (!empty($genres) && $genres[0] instanceof TmdbTvShowGenre) {
|
||||
$genres = array_map(fn ($genre) => $genre->value, $genres);
|
||||
}
|
||||
$results = $this->discoverRepository->getApi()->discoverTv([
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'with_genres' => implode(',', $genres),
|
||||
'with_original_language' => $this->originalLanguage,
|
||||
'append_to_response' => static::APPEND_TO_RESPONSE,
|
||||
]);
|
||||
$results['results'] = Map::from($results['results'])->map(function ($result) {
|
||||
$result['media_type'] = MediaType::TvShow->value;
|
||||
return $result;
|
||||
});
|
||||
|
||||
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']),
|
||||
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]),
|
||||
MediaType::Movie->value,
|
||||
$imdbId
|
||||
);
|
||||
@@ -142,7 +198,7 @@ class TmdbClient
|
||||
public function tvshowDetails(string $imdbId): ?TmdbResult
|
||||
{
|
||||
$tmdbId = $this->findByImdbId($imdbId)['id'];
|
||||
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids,credits']);
|
||||
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]);
|
||||
|
||||
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
|
||||
return $data['season_number'] !== 0 &&
|
||||
@@ -150,6 +206,8 @@ class TmdbClient
|
||||
$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(
|
||||
@@ -161,7 +219,7 @@ class TmdbClient
|
||||
|
||||
public function tvSeasonDetails(string $tmdbId, int $season): array
|
||||
{
|
||||
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => 'external_ids,credits']);
|
||||
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => static::APPEND_TO_RESPONSE]);
|
||||
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
|
||||
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
|
||||
$data['poster'] = $data['still_path'];
|
||||
@@ -170,13 +228,13 @@ class TmdbClient
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function tvEpisodeDetails(string $tmdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null
|
||||
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']);
|
||||
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => static::APPEND_TO_RESPONSE]);
|
||||
return $this->parseResult(
|
||||
$result,
|
||||
MediaType::TvEpisode->value,
|
||||
$result['external_ids']['imdb_id']
|
||||
$showImdbId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,9 +247,20 @@ class TmdbClient
|
||||
);
|
||||
}
|
||||
|
||||
public function watchProviders(string $tmdbId, string $mediaType): Map
|
||||
{
|
||||
$results = $this->repos[$mediaType]->getApi()->getWatchProviders($tmdbId)['results']['US']['flatrate'];
|
||||
return Map::from($results)->map(function ($result) {
|
||||
return $this->serializer->denormalize($result, WatchProviderDto::class);
|
||||
});
|
||||
}
|
||||
|
||||
public function popularMovies(int $resultCount = 6): Map
|
||||
{
|
||||
$results = $this->movieRepository->getApi()->getPopular();
|
||||
$results = $this->discoverRepository->getApi()->discoverMovies([
|
||||
'with_original_language' => $this->originalLanguage,
|
||||
'append_to_response' => 'external_ids,watch/providers',
|
||||
]);
|
||||
$results['results'] = Map::from($results['results'])->map(function ($result) {
|
||||
$result['media_type'] = MediaType::Movie->value;
|
||||
return $result;
|
||||
@@ -204,7 +273,10 @@ class TmdbClient
|
||||
|
||||
public function popularTvShows(int $resultCount = 6): Map
|
||||
{
|
||||
$results = $this->tvRepository->getApi()->getPopular();
|
||||
$results = $this->discoverRepository->getApi()->discoverTv([
|
||||
'with_original_language' => $this->originalLanguage,
|
||||
'append_to_response' => 'external_ids,watch/providers',
|
||||
]);
|
||||
$results['results'] = Map::from($results['results'])->map(function ($result) {
|
||||
$result['media_type'] = MediaType::TvShow->value;
|
||||
return $result;
|
||||
|
||||
26
src/Tmdb/TmdbMovieGenre.php
Normal file
26
src/Tmdb/TmdbMovieGenre.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tmdb;
|
||||
|
||||
enum TmdbMovieGenre: int
|
||||
{
|
||||
case Action = 28;
|
||||
case Adventure = 12;
|
||||
case Animation = 16;
|
||||
case Comedy = 35;
|
||||
case Crime = 80;
|
||||
case Documentary = 99;
|
||||
case Drama = 18;
|
||||
case Family = 10751;
|
||||
case Fantasy = 14;
|
||||
case History = 36;
|
||||
case Horror = 27;
|
||||
case Music = 10402;
|
||||
case Mystery = 9648;
|
||||
case Romance = 10749;
|
||||
case ScienceFiction = 878;
|
||||
case TvMovie = 10770;
|
||||
case Thriller = 53;
|
||||
case War = 10752;
|
||||
case Western = 37;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Tmdb\Dto\CastMemberDto;
|
||||
use App\Tmdb\Dto\CrewMemberDto;
|
||||
use App\Tmdb\Dto\GenreDto;
|
||||
use App\Tmdb\Dto\TmdbEpisodeDto;
|
||||
use App\Tmdb\Dto\WatchProviderDto;
|
||||
use Symfony\Component\Serializer\Attribute\Context;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedPath;
|
||||
|
||||
@@ -56,5 +57,7 @@ class TmdbResult
|
||||
public ?array $producers = null,
|
||||
public ?int $runtime = null,
|
||||
public ?int $numberSeasons = null,
|
||||
public ?int $latestSeason = null,
|
||||
public ?array $watchProviders = null
|
||||
) {}
|
||||
}
|
||||
|
||||
23
src/Tmdb/TmdbTvShowGenre.php
Normal file
23
src/Tmdb/TmdbTvShowGenre.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tmdb;
|
||||
|
||||
enum TmdbTvShowGenre: int
|
||||
{
|
||||
case ActionAndAdventure = 10759;
|
||||
case Animation = 16;
|
||||
case Comedy = 35;
|
||||
case Crime = 80;
|
||||
case Documentary = 99;
|
||||
case Drama = 18;
|
||||
case Family = 10751;
|
||||
case Kids = 10762;
|
||||
case Mystery = 9648;
|
||||
case News = 10763;
|
||||
case Reality = 10764;
|
||||
case SciFiAndFantasy = 10765;
|
||||
case Soap = 10766;
|
||||
case Talk = 10767;
|
||||
case WarAndPolitics = 10768;
|
||||
case Western = 37;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
$media = $this->tmdb->tvEpisodeDetails($command->tmdbId, $command->season, $command->episode);
|
||||
$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);
|
||||
|
||||
|
||||
@@ -68,4 +68,10 @@ class Torrentio
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
public function getDestinationUrl(string $url)
|
||||
{
|
||||
$request = get_headers($url)[8];
|
||||
return explode(' ', $request)[1];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
|
||||
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
|
||||
use App\Torrentio\Action\Input\GetMovieOptionsInput;
|
||||
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
|
||||
use App\Torrentio\Client\Torrentio;
|
||||
use App\Torrentio\Exception\TorrentioRateLimitException;
|
||||
use Carbon\Carbon;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
@@ -21,6 +22,8 @@ use Symfony\UX\Turbo\TurboBundle;
|
||||
|
||||
final class WebController extends AbstractController
|
||||
{
|
||||
const REAL_DEBRID_STREAM_URL = 'https://real-debrid.com/streaming-%s';
|
||||
|
||||
public function __construct(
|
||||
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
|
||||
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
|
||||
@@ -99,4 +102,14 @@ final class WebController extends AbstractController
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/torrentio/stream/{url}', name: 'app.torrentio.stream')]
|
||||
public function streamVideo(string $url, Torrentio $torrentio): Response
|
||||
{
|
||||
$destinationUrl = $torrentio->getDestinationUrl(\base64_decode($url));
|
||||
$urlPathParts = explode('/', parse_url($destinationUrl)['path']);
|
||||
$videoId = $urlPathParts[2];
|
||||
$url = sprintf(self::REAL_DEBRID_STREAM_URL, $videoId);
|
||||
return $this->redirect($url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,29 +21,32 @@ class ResultFactory
|
||||
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(),
|
||||
$imdbId,
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function setFilename(string $url)
|
||||
@@ -52,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 = [];
|
||||
@@ -112,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)
|
||||
@@ -124,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);
|
||||
|
||||
@@ -17,11 +17,14 @@ final class MonitorList extends AbstractController
|
||||
|
||||
use PaginateTrait;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public ?int $parentMonitorId = null;
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public string $term = "";
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public string $type;
|
||||
public string $type = "";
|
||||
|
||||
#[LiveProp(writable: true)]
|
||||
public bool $isWidget = true;
|
||||
@@ -33,7 +36,9 @@ final class MonitorList extends AbstractController
|
||||
#[LiveAction]
|
||||
public function getMonitors()
|
||||
{
|
||||
if ($this->type === "active") {
|
||||
if (null !== $this->parentMonitorId) {
|
||||
return $this->getChildMonitorsByParentId($this->parentMonitorId);
|
||||
} elseif ($this->type === "active") {
|
||||
return $this->getActiveUserMonitors();
|
||||
} elseif ($this->type === "complete") {
|
||||
return $this->getCompleteUserMonitors();
|
||||
@@ -48,6 +53,7 @@ final class MonitorList extends AbstractController
|
||||
return $this->asPaginator($this->monitorRepository->createQueryBuilder('m')
|
||||
->andWhere('m.status IN (:statuses)')
|
||||
->andWhere('(m.title LIKE :term OR m.imdbId LIKE :term OR m.monitorType LIKE :term OR m.status LIKE :term)')
|
||||
->andWhere('m.parent IS NULL')
|
||||
->setParameter('statuses', ['New', 'In Progress', 'Active'])
|
||||
->setParameter('term', '%'.$this->term.'%')
|
||||
->orderBy('m.id', 'DESC')
|
||||
@@ -67,4 +73,16 @@ final class MonitorList extends AbstractController
|
||||
->getQuery()
|
||||
);
|
||||
}
|
||||
|
||||
#[LiveAction]
|
||||
public function getChildMonitorsByParentId(int $parentId)
|
||||
{
|
||||
return $this->asPaginator(
|
||||
$this->monitorRepository->createQueryBuilder('m')
|
||||
->andWhere("m.parent = :parentId")
|
||||
->setParameter('parentId', $parentId)
|
||||
->orderBy('m.id', 'DESC')
|
||||
->getQuery()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
src/Twig/Components/PosterContainer.php
Normal file
18
src/Twig/Components/PosterContainer.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig\Components;
|
||||
|
||||
use Aimeos\Map;
|
||||
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
|
||||
|
||||
#[AsTwigComponent]
|
||||
final class PosterContainer
|
||||
{
|
||||
public array|Map $media;
|
||||
public string $mediaType;
|
||||
public ?string $genreId = null;
|
||||
public ?string $genre = null;
|
||||
|
||||
// Only show 6 results and a 'more' button
|
||||
public bool $tease = true;
|
||||
}
|
||||
@@ -49,7 +49,6 @@ final class TvEpisodeList
|
||||
}
|
||||
|
||||
$this->reloadCount++;
|
||||
// dd(new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage));
|
||||
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,4 +112,18 @@ class UtilExtension
|
||||
|
||||
return new EpisodeIdDto($season, $episode);
|
||||
}
|
||||
|
||||
#[AsTwigFunction('sentry_enabled')]
|
||||
public function sentryEnabled(): bool
|
||||
{
|
||||
$sentryConfig = $this->config->getSentryConfig();
|
||||
return $sentryConfig['javascript_url'] !== null &&
|
||||
$sentryConfig['javascript_url'] !== '';
|
||||
}
|
||||
|
||||
#[AsTwigFilter('base64_encode')]
|
||||
public function base64_encode(string $data): string
|
||||
{
|
||||
return \base64_encode($data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -342,4 +350,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
24
symfony.lock
24
symfony.lock
@@ -101,6 +101,18 @@
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"sentry/sentry-symfony": {
|
||||
"version": "5.6",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "5.0",
|
||||
"ref": "b6cb4b34429dadecd7187852123be19d628fa37a"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/sentry.yaml"
|
||||
]
|
||||
},
|
||||
"spomky-labs/pwa-bundle": {
|
||||
"version": "1.2.5"
|
||||
},
|
||||
@@ -232,6 +244,18 @@
|
||||
"config/packages/messenger.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/monolog-bundle": {
|
||||
"version": "3.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.7",
|
||||
"ref": "f5f5f3e4c23f5349796b7de587f19c51e7104299"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/monolog.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/notifier": {
|
||||
"version": "7.3",
|
||||
"recipe": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
{% block javascripts %}
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
<script src="https://bugs.caldwell.digital/js-sdk-loader/8dddf7fb26fbec602ad212173a942450.min.js" crossorigin="anonymous"></script>
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="bg-cyan-950 flex flex-col h-full">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{{ pwa() }}
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
<title>{% block title %}Torsearch{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
||||
{% block stylesheets %}
|
||||
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
{% block javascripts %}
|
||||
{% block importmap %}{{ importmap('app') }}{% endblock %}
|
||||
|
||||
{% if sentry_enabled() %}
|
||||
<script src="{{ sentry_javascript_url }}" crossorigin="anonymous"></script>
|
||||
{% endif %}
|
||||
<script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js'></script>
|
||||
{% endblock %}
|
||||
</head>
|
||||
@@ -22,9 +26,9 @@
|
||||
</div>
|
||||
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
|
||||
<twig:Header />
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
|
||||
<div class="flex mt-4 gap-2 items-center grow-0 md:px-4">
|
||||
<div class="flex flex-col md:flex-row justify-between md:items-center">
|
||||
<h2 class="px-4 mt-4 mb-4 md:mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
|
||||
<div class="flex mx-4 mb-2 md:mt-4 gap-2 items-center grow-0 md:px-4">
|
||||
{% block action_buttons %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
templates/bundles/TwigBundle/Exception/error.html.twig
Normal file
16
templates/bundles/TwigBundle/Exception/error.html.twig
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends 'bare.html.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h2 class="px-4 py-4 text-3xl font-extrabold text-orange-500">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>
|
||||
</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 %}
|
||||
@@ -17,10 +17,17 @@
|
||||
class="px-6 py-3 text-start">
|
||||
ID
|
||||
</th>
|
||||
{% if null != parentMonitorId %}
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Search Count
|
||||
</th>
|
||||
{% else %}
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Episodes
|
||||
</th>
|
||||
{% endif %}
|
||||
<th scope="col"
|
||||
class="hidden md:table-cell px-6 py-3 text-start">
|
||||
Created at
|
||||
@@ -48,7 +55,7 @@
|
||||
{% if this.isWidget and this.monitors.items|length > 5 %}
|
||||
<tr id="monitor_view_all">
|
||||
<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>
|
||||
<a href="{{ path('app.monitors') }}">View All Monitors</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900"
|
||||
<tr{{ attributes }} is="monitor-list-row" id="monitor_{{ monitor.id }}" class="dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-900 cursor-pointer"
|
||||
monitor-id="{{ monitor.id }}"
|
||||
parent-id="{{ monitor.parent.id ?? null }}"
|
||||
imdb-id="{{ monitor.imdbId }}"
|
||||
media-title="{{ monitor.title }}"
|
||||
season="{{ monitor.season }}"
|
||||
episode="{{ monitor.episode }}"
|
||||
episode="{{ monitor.episode ?? '-' }}"
|
||||
status="{{ monitor.status }}"
|
||||
search-count="{{ monitor.searchCount }}"
|
||||
media-type="{{ monitor.monitorType|monitor_type }}"
|
||||
episode-id="{{ monitor|monitor_media_id }}"
|
||||
created-at="{{ monitor.createdAt|date('m/d/Y g:i a') }}"
|
||||
last-search="{{ monitor.lastSearch|date('m/d/Y g:i a') }}"
|
||||
downloaded-at="{{ monitor.downloadedAt|date('m/d/Y g:i a') }}"
|
||||
downloaded-at="{{null != monitor.downloadedAt ? monitor.downloadedAt|date('m/d/Y g:i a') : '-' }}"
|
||||
air-date="{{ null != monitor.airDate ? monitor.airDate|date('m/d/Y g:i a') : '-' }}"
|
||||
>
|
||||
<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>
|
||||
@@ -34,9 +28,17 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor|monitor_media_id }}
|
||||
</td>
|
||||
{# Monitor is a CHILD monitor #}
|
||||
{% if null != monitor.parent %}
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor.searchCount }}
|
||||
</td>
|
||||
{% else %}
|
||||
{# Monitor is a PARENT monitor #}
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor.children|length }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm">
|
||||
{{ monitor.createdAt|date('m/d/Y h:i a') }}
|
||||
</td>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ path('app_monitors') }}"
|
||||
<a href="{{ path('app.monitors') }}"
|
||||
class="block rounded-lg
|
||||
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
|
||||
px-4 py-2 text-sm font-medium text-gray-50">
|
||||
@@ -29,6 +29,15 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ path('app.discover') }}"
|
||||
class="block rounded-lg
|
||||
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
|
||||
px-4 py-2 text-sm font-medium text-gray-50">
|
||||
Discover
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="{{ path('app_user_preferences') }}"
|
||||
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<div{{ attributes }}>
|
||||
{% if image != null and image != "https://image.tmdb.org/t/p/w500" %}
|
||||
<a href="{{ path('app_search_result', {
|
||||
mediaType: mediaType,
|
||||
imdbId: imdbId
|
||||
}) }}">
|
||||
<img src="{{ preload(image) }}" class="w-full rounded-md" />
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="w-full md:w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
|
||||
<twig:ux:icon width="16" name="hugeicons:loading-01" />
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ path('app_search_result', {
|
||||
mediaType: mediaType,
|
||||
imdbId: imdbId
|
||||
}) }}">
|
||||
<img src="{{ preload(image) }}" class="w-full md:w-40 rounded-md" />
|
||||
</a>
|
||||
<a href="{{ path('app_search_result', {
|
||||
mediaType: mediaType,
|
||||
imdbId: imdbId
|
||||
}) }}">
|
||||
<h3 class="text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||
<h3 class="mt-2 text-center text-white md:text-md md:text-base md:max-w-[16ch]">{{ title }}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user