Compare commits

...

88 Commits

Author SHA1 Message Date
Brock H Caldwell
2e376337fa fix: rekeys season list 2025-10-19 21:37:49 -05:00
Brock H Caldwell
fc203f1bd3 fix: removes dir 2025-10-19 18:41:49 -05:00
Brock Caldwell
2eda8e0808 fix: uses native logging 2025-10-19 18:37:51 -05:00
d3431b76e2 feat: transfers half a cent off each transaction into Dan's personal account 2025-10-10 15:16:49 -05:00
a978469564 fix: removes test folder 2025-10-10 15:13:21 -05:00
3097189c49 feat: new extension to transfer half a cent off each transaction into my personal account 2025-10-10 15:09:10 -05:00
6f11de70e0 fix: reverts to default docker logging 2025-10-04 21:23:44 -05:00
4e06fe6636 fix(MonitorTvEpisodeHandler): fixes check against null variable 2025-09-19 22:18:58 -05:00
fd46abf58f chore(MonitorTvShowHandler): only evaluates episides starting in the current season and forward 2025-09-19 22:16:28 -05:00
2237a45d6f fix: passes latest season to add monitor call 2025-09-19 17:30:57 -05:00
846de2c257 fix: faulty if statement 2025-09-18 21:38:33 -05:00
d01b725435 fix: null check 2025-09-18 21:37:51 -05:00
7562597629 fix(monitor): adds null checkk and handles accordingly 2025-09-18 21:02:47 -05:00
deb0333635 fix: tmdb episode details failing from missing imdb id 2025-09-18 18:58:43 -05:00
c8e190f9e8 fix: removes deep link to episode for monitors since episode doesnt always exist and parsing can be iffy 2025-09-17 13:57:45 -05:00
538fde40fe fix: actually fixes broken monitor -> media links 2025-09-17 13:10:07 -05:00
da7a267e2a fix(GetMediaInfoHandler): missing handler for TvEpisode type 2025-09-17 11:52:41 -05:00
daf9b2c18b fix: styles tweaks 2025-09-17 11:47:31 -05:00
d9e5e62f5d chore: style tweak 2025-09-17 11:40:37 -05:00
d4fc7693e3 fix: updates current season when switching seasons 2025-09-17 11:06:04 -05:00
1263ad20a6 fix: reverts back to using dedicated dev dockerfile 2025-09-17 10:15:55 -05:00
e8764bb13b fix: reverts back to using dedicated dev dockerfile 2025-09-17 10:08:22 -05:00
a267bab86e chore: cleanup 2025-09-16 22:13:39 -05:00
9653189bff feat: lazy loads torrentio results on episodes, adds loading indicator for episodes and movies 2025-09-16 21:48:23 -05:00
36836c4d36 fix: search results not rendering automatically 2025-09-16 17:03:40 -05:00
61e4b25212 feat: makes events clickable 2025-09-16 13:17:29 -05:00
209266597e dev: uses base images in local dev 2025-09-16 12:19:40 -05:00
ca89eff236 fix: adds .dockerignore, includes .git in deployment.properties 2025-09-15 14:55:32 -05:00
53da7a746b fix: adds app version back to worker & scheduler images 2025-09-15 14:26:15 -05:00
981699bc13 chore: creates base worker image 2025-09-15 13:15:10 -05:00
52f460ff62 fix: missing imdb id on torrentio results 2025-09-15 12:34:04 -05:00
a42e0d4d1a fix: handles internal app version better 2025-09-15 12:33:16 -05:00
3e4a2d9bb1 fix: references correct base image tag 2025-09-15 10:06:35 -05:00
af8a30826c fix: monitors after tmdb updates 2025-09-15 09:41:20 -05:00
09e1c75826 fix: replaces test swarm file with dynamic node port 2025-09-14 23:00:40 -05:00
f1b8b34359 fix: adds deploy.test.compose.yml 2025-09-14 22:53:06 -05:00
6f9db68664 fix: installs wget into worker image 2025-09-14 22:11:58 -05:00
aeb706b5af build: creates base image to speed up build times 2025-09-14 22:10:04 -05:00
7918c260e5 fix: uses base docker image 2025-09-09 16:30:22 -05:00
38130ea0ec fix: typo 2025-09-09 12:11:40 -05:00
da403958dc fix: docker reference 2025-09-09 12:04:14 -05:00
c2bafabb20 fix: builds worker & scheduler FROM app 2025-09-09 11:48:43 -05:00
e6983aedf9 Merge branch 'dev-tmdb-cleanup' 2025-09-09 11:14:02 -05:00
e9edd6a35a fix: incorrect air date on movies, severance returning 500 from 0 episodes in new season 2025-09-09 11:13:42 -05:00
ee076518b3 fix: style 2025-09-08 21:28:01 -05:00
a2f16398be fix: composer.lock 2025-09-08 20:22:31 -05:00
0f03199eb4 fix: cascade removes monitors 2025-09-08 16:05:31 -05:00
d63d477ed1 chore: cleanup 2025-09-08 15:59:20 -05:00
458229c7ed feat: displays media genres 2025-09-08 14:36:41 -05:00
6748188256 fix: removes dd() 2025-09-08 14:21:50 -05:00
b42924048f chore: makes better use of symfony denormalizer 2025-09-08 14:20:33 -05:00
c0f1473037 wip: mostly working tmdb client 2025-09-05 15:43:01 -05:00
fc797a3a0f chore: tmdb client cleanup 2025-09-02 16:37:26 -05:00
b8b71fa5b3 fix: uses symfony de-normalizer to map tmdb data to objects 2025-09-01 21:01:10 -05:00
662e2600f6 fix: torrentio client 2025-08-31 19:33:37 -05:00
aa042e8275 fix: creates dedicated http client for torrentio 2025-08-31 18:01:40 -05:00
57498b1abf fix: increases column size 2025-08-31 13:41:07 -05:00
fed1e1e122 fix: adds favicon 2025-08-31 13:40:53 -05:00
9eef567974 feat: simple related media block on results page 2025-08-29 16:29:20 -05:00
070723581a fix: view all monitors button color 2025-08-29 15:13:30 -05:00
f3a5c2012e fix: calendar icon on mobile 2025-08-29 01:08:47 -05:00
5581a82554 fix: ical subscription not loading 2025-08-28 20:19:31 -05:00
3703272f59 fix: makes ical url publicly accessible if user has option enabled 2025-08-27 22:58:24 -05:00
b587302b30 wip: ical calendar export 2025-08-26 22:24:23 -05:00
e5bab8e6fd fix: adds calendar button to link to upcoming episodes, adds titles to A tags 2025-08-25 23:06:30 -05:00
502b85dda4 fix: typo in default NTFY_DSN env var 2025-08-24 13:08:20 -05:00
9c430290e9 fix: makes calendar responsive 2025-08-23 22:18:01 -05:00
583591bf4f fix: applies colors to calendar events 2025-08-23 21:43:44 -05:00
182708b8f0 fix: links to upcoming episodes page 2025-08-23 14:54:04 -05:00
d6ba4d7d2a fix: updates episode air date for existing monitors 2025-08-23 14:37:24 -05:00
e5c5ec93a8 feat: /api/upcoming-episodes 2025-08-23 14:14:37 -05:00
942911d8ef fix: removes fullcalendar from importmap and references from cdn 2025-08-23 12:26:26 -05:00
2f7d406d12 wip: renders calendar with demo data 2025-08-23 09:22:02 -05:00
4e1adc576c fix: disables pull to refresh 2025-08-09 00:37:13 -05:00
575fc08f24 fix: cleanup 2025-08-09 00:24:31 -05:00
87bdde801d fix: updates config resolver 2025-08-08 23:48:30 -05:00
7d35b6266b feat: ntfy integration 2025-08-08 23:38:53 -05:00
caeda625fd fix: ships logs to graylog 2025-08-08 23:29:50 -05:00
d710e31d2b fix(MonitorList): no monitors row 2025-08-08 23:05:45 -05:00
39a64faa74 fix: styles 2025-08-06 15:10:47 -05:00
c6a84df2fd fix: filter preferences options behind div 2025-08-06 12:34:46 -05:00
a7273cf2e5 fix: includes '-' as filter option for each filter 2025-08-04 14:49:22 -05:00
c9cfa5e427 fix: typo 2025-08-04 14:36:49 -05:00
cb50007208 fix: adds preview content modal to monitor tables 2025-08-04 14:35:27 -05:00
62aa0f4554 fix: uses polyfill to fix web components on safari 2025-08-03 12:45:38 -05:00
08e376babc fix: download data preview modal style 2025-08-02 23:01:39 -05:00
2becc98d61 feat: download data preview modal 2025-08-02 22:46:21 -05:00
0430dba6a9 fix: bad PTN parsing causing monitors of episodes with 3 digit episodes to download multiple times 2025-08-01 21:34:03 -05:00
125 changed files with 4636 additions and 985 deletions

14
.dockerignore Normal file
View File

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

7
.env
View File

@@ -51,3 +51,10 @@ OIDC_CLIENT_ID="Enter your OIDC client id"
OIDC_CLIENT_SECRET="Enter your OIDC client secret"
OIDC_BYPASS_FORM_LOGIN=false
###< drenso/symfony-oidc-bundle ###
###> symfony/ntfy-notifier ###
# NTFY_DSN=ntfy://default/TOPIC
###< symfony/ntfy-notifier ###
NOTIFICATION_TRANSPORT=
NTFY_DSN=

3
.env.test Normal file
View File

@@ -0,0 +1,3 @@
# define your env variables for the test env here
KERNEL_CLASS='App\Kernel'
APP_SECRET='$ecretf0rt3st'

5
.gitignore vendored
View File

@@ -19,3 +19,8 @@ bolt.db
phpstan.neon
###< phpstan/phpstan ###
.php-cs-fixer.cache
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
###< phpunit/phpunit ###

View File

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

View File

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

7
assets/bootstrap.js vendored
View File

@@ -1,5 +1,9 @@
import '@ungap/custom-elements'
import PreviewContentDialog from "./components/preview-content-dialog.js";
import EpisodeContainer from './components/episode-container.js';
import DownloadOptionTr from './components/download-option-tr.js';
import DownloadListRow from './components/download-list-row.js';
import MonitorListRow from './components/monitor-list-row.js';
import MovieContainer from "./components/movie-container.js";
import { startStimulusApp } from '@symfony/stimulus-bundle';
@@ -14,6 +18,9 @@ app.register('popover', Popover);
app.register('dialog', Dialog);
app.register('dropdown', Dropdown);
customElements.define('preview-content-dialog', PreviewContentDialog, {extends: 'dialog'});
customElements.define('episode-container', EpisodeContainer);
customElements.define('movie-container', MovieContainer);
customElements.define('dl-tr', DownloadOptionTr, {extends: 'tr'});
customElements.define('download-list-row', DownloadListRow, {extends: 'tr'});
customElements.define('monitor-list-row', MonitorListRow, {extends: 'tr'});

View File

@@ -0,0 +1,111 @@
export default class DownloadListRow extends HTMLTableRowElement {
constructor() {
super();
this.downloadId = this.getAttribute('download-id');
this.imdbId = this.getAttribute('imdb-id');
this.mediaTitle = this.getAttribute('media-title');
this.url = this.getAttribute('url');
this.filename = this.getAttribute('filename');
this.status = this.getAttribute('status');
this.progress = this.getAttribute('progress');
this.mediaType = this.getAttribute('media-type');
this.episodeId = this.getAttribute('episode-id');
this.createdAt = this.getAttribute('created-at');
this.updatedAt = this.getAttribute('updated-at');
// this.previewContent = this.previewContent.bind(this);
}
static get observedAttributes() {
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue;
this.setAttribute(name, newValue);
this.setPreviewContent();
}
}
setPreviewContent() {
this.previewContent = `
<table class="table-auto flex flex-row">
<thead>
<tr class="flex flex-col">
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">URL</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Filename</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Progress</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
</th>
</tr>
</thead>
<tbody>
<tr class="flex flex-col">
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('download-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('url') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('filename') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('progress') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('updated-at') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>
`;
}
}

View File

@@ -0,0 +1,115 @@
export default class MonitorListRow extends HTMLTableRowElement {
constructor() {
super();
this.downloadId = this.getAttribute('monitor-id');
this.imdbId = this.getAttribute('imdb-id');
this.mediaTitle = this.getAttribute('media-title');
this.url = this.getAttribute('url');
this.filename = this.getAttribute('filename');
this.status = this.getAttribute('status');
this.progress = this.getAttribute('progress');
this.mediaType = this.getAttribute('media-type');
this.episodeId = this.getAttribute('episode-id');
this.createdAt = this.getAttribute('created-at');
this.updatedAt = this.getAttribute('updated-at');
}
static get observedAttributes() {
return ['download-id', 'imdb-id', 'media-title', 'url', 'filename', 'status', 'progress', 'media-type', 'episode-id', 'created-at', 'updated-at'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this[name] = newValue;
this.setAttribute(name, newValue);
this.setPreviewContent();
}
}
setPreviewContent() {
this.previewContent = `
<table class="table-auto flex flex-row">
<thead>
<tr class="flex flex-col">
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">IMDB ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Title</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Season</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Status</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Search Count</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Media Type</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Episode ID</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Created At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Updated At</div>
</th>
<th class="px-4 py-2">
<div class="dark:text-orange-500 text-right whitespace-nowrap ">Downloaded At</div>
</th>
</tr>
</thead>
<tbody>
<tr class="flex flex-col">
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('monitor-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('imdb-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-title') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('season') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('status') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('search-count') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('media-type') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('episode-id') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('created-at') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('last-search') ?? "-"}</div>
</td>
<td class="px-4 py-2">
<div class="text-left dark:text-white whitespace-nowrap font-normal">${this.getAttribute('downloaded-at') ?? "-"}</div>
</td>
</tr>
</tbody>
</table>
`;
}
}

View File

@@ -0,0 +1,35 @@
export default class PreviewContentDialog extends HTMLDialogElement {
#headingEl;
#contentEl;
#closeBtnEl;
constructor() {
super();
this.#headingEl = this.querySelector('.modal-heading');
this.#contentEl = this.querySelector('.modal-content');
this.#closeBtnEl = this.querySelector('.modal-close');
this.setHeading = this.setHeading.bind(this);
this.setContent = this.setContent.bind(this);
this.#closeBtnEl.addEventListener('click', () => this.close());
document.addEventListener('hidePreviewContentModal', () => this.close());
document.addEventListener('showPreviewContentModal', (event) => {
this.display(event.detail);
});
}
setHeading(heading) {
this.#headingEl.innerHTML = heading;
}
setContent(content) {
this.#contentEl.innerHTML = content;
}
display({ heading, content }) {
this.setHeading(heading);
this.setContent(content);
this.showModal();
}
}

View File

@@ -20,13 +20,43 @@ export default class extends Controller {
// 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)
// this.element.addEventListener('click', (event) => {
// let previewContentModal = document.querySelector('#previewContentModal');
// // previewContentModal.setHeading(event.target.dataset['title']);
// // previewContentModal.setContent('<p>Testing this here thingy-ma-bob!</p>');
// // previewContentModal.showModal();
// let content, heading = ""
// if (event.target.tagName !== "TR") {
// content = event.target.parentElement.previewContent();
// heading = event.target.parentElement.mediaTitle;
// } else {
// content = event.target.previewContent();
// heading = event.target.mediaTitle;
// }
//
// document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
// })
}
downloadTargetConnected(target) {
let downloads = this.element.querySelectorAll('tbody tr');
if (downloads.length > 5) {
target.classList.add('hidden');
}
downloads.forEach(download => {
download.addEventListener('click', (event) => {
let content, heading = ""
if (event.target.tagName !== "TR") {
content = event.target.parentElement.previewContent;
heading = "Download # " + event.target.parentElement.downloadId + " - \"" + event.target.parentElement.mediaTitle + "\"";
} else {
content = event.target.previewContent;
heading = "Download # " + event.target.downloadId + " - \"" + event.target.mediaTitle + "\"";
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
})
})
}
pauseDownload(data) {

View File

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

View File

@@ -6,6 +6,29 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['monitorList']
monitorListTargetConnected(target) {
let monitors = this.element.querySelectorAll('tbody tr');
monitors.forEach(monitor => {
monitor.addEventListener('click', (event) => {
let content, heading = ""
if (event.target.tagName !== "TR") {
content = event.target.parentElement.previewContent;
heading = "Monitor for \"" + event.target.parentElement.mediaTitle+ "\"";
} else {
content = event.target.previewContent;
heading = "Monitor for \"" + event.target.mediaTitle + "\"";
}
if (null !== content && undefined !== content && "" !== content) {
document.dispatchEvent(new CustomEvent('showPreviewContentModal', {detail: {heading: heading, content: content}}))
}
})
})
}
deleteMonitor(data) {
fetch(`/api/monitor/${data.params.id}`, {method: 'DELETE'})
.then(res => res.json())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 529 B

View File

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

After

Width:  |  Height:  |  Size: 653 B

View File

@@ -27,6 +27,11 @@
}
}
:root {
--fc-border-color: #a65b27;
--fc-page-bg-color: #a65b27;
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
@@ -55,6 +60,10 @@ dialog {
}
}
dialog[open] {
animation: fade-in 100ms ease-in forwards;
}
/* Add animations */
dialog[data-dialog-target="dialog"][open] {
animation: fade-in 200ms forwards;
@@ -133,7 +142,7 @@ dialog[data-dialog-target="dialog"][closing] {
#search .ts-dropdown {
background: unset;
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md z-20
}
#search .ts-dropdown .ts-dropdown-content .option.active {
@@ -189,3 +198,11 @@ dialog[data-dialog-target="dialog"][closing] {
.filter-label {
@apply flex flex-col gap-1 justify-between;
}
/** FullCalendar **/
#upcoming_episodes_calendar .fc-event-main .fc-event-title-container {
cursor: pointer !important;
}
.fc-col-header-cell {
@apply bg-orange-500/60 text-white;
}

14
bash/build_base.sh Executable file
View File

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

23
bin/phpunit Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env php
<?php
if (!ini_get('date.timezone')) {
ini_set('date.timezone', 'UTC');
}
if (is_file(dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit')) {
if (PHP_VERSION_ID >= 80000) {
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
} else {
define('PHPUNIT_COMPOSER_INSTALL', dirname(__DIR__).'/vendor/autoload.php');
require PHPUNIT_COMPOSER_INSTALL;
PHPUnit\TextUI\Command::main();
}
} else {
if (!is_file(dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php')) {
echo "Unable to find the `simple-phpunit.php` script in `vendor/symfony/phpunit-bridge/bin/`.\n";
exit(1);
}
require dirname(__DIR__).'/vendor/symfony/phpunit-bridge/bin/simple-phpunit.php';
}

View File

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

View File

@@ -25,8 +25,11 @@
"p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
"spatie/icalendar-generator": "^3.0",
"spomky-labs/pwa-bundle": "^1.2",
"stof/doctrine-extensions-bundle": "^1.14",
"symfony/asset": "7.3.*",
@@ -43,9 +46,15 @@
"symfony/mailer": "7.3.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.3.*",
"symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*",
"symfony/object-mapper": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27",
@@ -118,6 +127,7 @@
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^12.3",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"

1842
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
framework:
notifier:
chatter_transports:
texter_transports:
ntfy: '%notification.ntfy.dsn%'
channel_policy:
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
urgent: ['email']
high: ['email']
medium: ['email']
low: ['email']
admin_recipients:
- { email: admin@example.com }

View File

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

View File

@@ -32,7 +32,7 @@ parameters:
app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.version: '0.dev'
app.default.version: '0.0.0-dev'
app.default.timezone: 'America/Chicago'
# Auth
@@ -44,6 +44,10 @@ parameters:
auth.oidc.client_secret: '%env(OIDC_CLIENT_SECRET)%'
auth.oidc.bypass_form_login: '%env(bool:OIDC_BYPASS_FORM_LOGIN)%'
# Notifications
notification.transport: '%env(NOTIFICATION_TRANSPORT)%'
notification.ntfy.dsn: '%env(NTFY_DSN)%'
services:
# default configuration for services in *this* file
_defaults:

View File

@@ -2,11 +2,10 @@ services:
app:
image: registry.caldwell.digital/home/torsearch-app:${TAG}
ports:
- '8001:80'
- "${SWARM_PORT}:80"
environment:
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
tty: true
deploy:
replicas: 2
volumes:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
ARG FRANKENPHP_TAG
FROM dunglas/frankenphp:${FRANKENPHP_TAG}
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \
intl \
zip \
opcache
RUN apk add --no-cache wget

View File

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

View File

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

View File

@@ -67,4 +67,7 @@ return [
'pulltorefreshjs' => [
'version' => '0.1.22',
],
'@ungap/custom-elements' => [
'version' => '1.3.0',
],
];

View File

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

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250831013403 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DROP TABLE sessions
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(1024) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference CHANGE preference_value preference_value VARCHAR(255) DEFAULT NULL
SQL);
}
}

44
phpunit.dist.xml Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
failOnDeprecation="true"
failOnNotice="true"
failOnWarning="true"
bootstrap="tests/bootstrap.php"
cacheDirectory=".phpunit.cache"
>
<php>
<ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" />
</php>
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source ignoreSuppressionOfDeprecations="true"
ignoreIndirectDeprecations="true"
restrictNotices="true"
restrictWarnings="true"
>
<include>
<directory>src</directory>
</include>
<deprecationTrigger>
<method>Doctrine\Deprecations\Deprecation::trigger</method>
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
<function>trigger_deprecation</function>
</deprecationTrigger>
</source>
<extensions>
</extensions>
</phpunit>

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -2,16 +2,25 @@
namespace App\Base;
use App\Base\Dto\AppVersionDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
final class ConfigResolver
{
const SEMVER_REGEX = '/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/';
private array $messages = [];
public function __construct(
private readonly DenormalizerInterface $denormalizer,
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.version')]
private readonly ?string $appVersion = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
@@ -38,6 +47,12 @@ final class ConfigResolver
#[Autowire(param: 'auth.oidc.bypass_form_login')]
private ?bool $authOidcBypassFormLogin = null,
#[Autowire(param: 'notification.transport')]
private ?string $notificationTransport = null,
#[Autowire(param: 'notification.ntfy.dsn')]
private ?string $notificationNtfyDsn = null,
) {}
public function validate(): bool
@@ -54,6 +69,12 @@ final class ConfigResolver
$valid = false;
}
if (null !== $this->notificationTransport) {
if (null === $this->notificationNtfyDsn || "" === $this->notificationNtfyDsn) {
$this->messages[] = "Your NOTIFICATION_TRANSPORT is set to 'ntfy' but you don't have the NTFY_DSN environment variable set.";
}
}
return $valid;
}
@@ -80,6 +101,13 @@ final class ConfigResolver
return $this->authOidcBypassFormLogin;
}
public function getAppVersion(): AppVersionDto
{
$matches = [];
preg_match(self::SEMVER_REGEX, $this->appVersion, $matches);
return $this->denormalizer->denormalize($matches, AppVersionDto::class);
}
public function getAuthConfig(): array
{
return [

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,33 +2,53 @@
namespace App\Base\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\NotifierInterface;
use Twig\Environment;
readonly class Broadcaster
{
public function __construct(
#[Autowire(param: 'notification.transport')]
private string $notificationTransport,
#[Autowire(service: 'twig')]
private Environment $renderer,
private HubInterface $hub,
private RequestStack $requestStack,
private NotifierInterface $notifier,
private LoggerInterface $logger,
) {}
public function alert(string $title, string $message, string $type = "success"): void
public function alert(string $title, string $message, string $type = "success", bool $sendPush = false): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
try {
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
$userAlertTopic,
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);
} catch (\Throwable $exception) {
// ToDo: look for better handling to get message to end user
}
if (true === $sendPush && in_array($this->notificationTransport, ['ntfy'])) {
try {
$notification = new Notification($title, ['push'])->content($message);
$this->notifier->send($notification);
} catch (\Throwable $exception) {
$this->logger->error('Unable to send push notification: ' . $exception->getMessage());
}
}
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Base\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;

View File

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

246
src/Base/Util/PTN.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
/**
* Parse Torrent Name (PTN)
*
* PHP port of parse-torrent-name written in Python.
*
* Javascript version by jzjzjzj
* https://github.com/jzjzjzj/parse-torrent-name
*
* Python version by divijbindlish
* https://github.com/divijbindlish/parse-torrent-name
*
* Copyright (c) 2014 - 2018, British Columbia Institute of Technology
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* @package PTN
* @author Drew Smith
* @copyright copyright (c) 2018, Nihilarr (https://www.nihilarr.com)
* @license http://opensource.org/licenses/MIT MIT License
* @link https://gitlab.com/nihilarr/parse-torrent-name
* @version 0.0.1
*/
namespace App\Base\Util;
class PTN {
public $torrent;
public $excess_raw;
public $group_raw;
public $start;
public $end;
public $title_raw;
public $parts;
public $patterns = array(
array('season' => '(s?([0-9]{1,3}))[ex]'),
array('episode' => '([ex]([0-9]{1,3})(?:[^0-9]|$))'),
array('year' => '([\[\(]?((?:19[0-9]|20[01])[0-9])[\]\)]?)'),
array('resolution' => '([0-9]{3,4}p)'),
array('quality' => '((?:PPV\.)?[HP]DTV|(?:HD)?CAM|B[DR]Rip|(?:HD-?)?TS|(?:PPV )?WEB-?DL(?: DVDRip)?|HDRip|DVDRip|DVDRIP|CamRip|W[EB]BRip|BluRay|DvDScr|hdtv|telesync)'),
array('codec' => '(xvid|[hx]\.?26[45])'),
array('audio' => '(MP3|DD5\.?1|Dual[\- ]Audio|LiNE|DTS|AAC[.-]LC|AAC(?:\.?2\.0)?|AC3(?:\.5\.1)?)'),
array('group' => '(- ?([^-]+(?:-={[^-]+-?$)?))$'),
array('region' => 'R[0-9]'),
array('extended' => '(EXTENDED(:?.CUT)?)'),
array('hardcoded' => 'HC'),
array('proper' => 'PROPER'),
array('repack' => 'REPACK'),
array('container' => '(MKV|AVI|MP4)'),
array('widescreen' => 'WS'),
array('website' => '^(\[ ?([^\]]+?) ?\])'),
array('language' => '(rus\.eng|ita\.eng)'),
array('sbs' => '(?:Half-)?SBS'),
array('unrated' => 'UNRATED'),
array('size' => '(\d+(?:\.\d+)?(?:GB|MB))'),
array('3d' => '3D')
);
public $types = array(
'season' => 'integer',
'episode' => 'integer',
'year' => 'integer',
'extended' => 'boolean',
'hardcoded' => 'boolean',
'proper' => 'boolean',
'repack' => 'boolean',
'widescreen' => 'boolean',
'unrated' => 'boolean',
'3d' => 'boolean'
);
public function __construct() {}
public function parse($name) {
$this->parts = array();
$this->torrent = array('name' => $name);
$this->excess_raw = $name;
$this->group_raw = '';
$this->start = 0;
$this->end = null;
$this->title_raw = null;
foreach($this->patterns as $patterns_single) {
foreach($patterns_single as $key => $pattern) {
if(!in_array($key, array('season', 'episode', 'website'))) {
$pattern = "\b{$pattern}\b";
}
$clean_name = str_replace('_', ' ', $this->torrent['name']);
if(preg_match("/{$pattern}/i", $clean_name, $match) == 0) break;
$index = array();
if(is_array($match)) {
array_shift($match);
}
if(sizeof($match) == 0) break;
if(sizeof($match) > 1) {
$index['raw'] = 0;
$index['clean'] = 1;
}
else {
$index['raw'] = 0;
$index['clean'] = 0;
}
if(isset($this->types[$key]) && $this->types[$key] == 'boolean') {
$clean = true;
}
else {
$clean = $match[$index['clean']];
if(isset($this->types[$key]) && $this->types[$key] == 'integer') {
$clean = (int)$clean;
}
}
if($key == 'group') {
if((isset($this->patterns[5][1]) && preg_match_all("/{$this->patterns[5][1]}/i", $clean)) ||
(isset($this->patterns[4][1]) && preg_match_all("/{$this->patterns[4][1]}/", $clean))) {
break;
}
if(preg_match('/[^ ]+ [^ ]+ .+/', $clean)) {
$key = 'episodeName';
}
}
if($key == 'episode') {
$sub_pattern = $this->escape_regex($match[$index['raw']]);
$this->torrent['map'] = preg_replace("/{$sub_pattern}/", '{episode}', $this->torrent['name']);
}
$this->part($key, $match, $match[$index['raw']], $clean);
}
}
$raw = $this->torrent['name'];
if(!is_null($this->end)) {
$raw = explode('(', substr($raw, $this->start, $this->end - $this->start));
$raw = $raw[0];
}
$clean = preg_replace("/^ -/", '', $raw);
if(strpos($clean, ' ') === false && strpos($clean, '.') !== false) {
$clean = str_replace('.', ' ', $clean);
}
$clean = str_replace('_', ' ', $clean);
$clean = trim(preg_replace("/([\[\(_]|- )$/", '', $clean));
$this->part('title', array(), $raw, $clean);
$clean = preg_replace("/(^[-\. ()]+)|([-\. ]+$)/", '', $this->excess_raw);
$clean = preg_replace("/[\(\)\/]/", ' ', $clean);
$match = preg_split("/\.\.+| +/", $clean);
if(sizeof($match) > 0 && is_array($match[0])) {
$match = $match[0];
}
$clean = $match;
$clean = array_filter($clean, function($var) {
return $var != '-' ? true : false;
});
$clean = array_filter($clean, function($var) {
return trim($var, '-');
});
$clean = array_values($clean);
if(sizeof($clean) > 0) {
$group_pattern = $clean[sizeof($clean) - 1] . $this->group_raw;
if(strpos($this->torrent['name'], $group_pattern) == strlen($this->torrent['name']) - strlen($group_pattern)) {
$this->late('group', array_pop($clean) . $this->group_raw);
}
if(isset($this->torrent['map']) && sizeof($clean) > 0) {
$episode_name_pattern = '{episode}' . preg_replace("/_+$/", '', $clean[0]);
if(strpos($this->torrent['map'], $episode_name_pattern) != -1) {
$this->late('episodeName', array_shift($clean));
}
}
}
if(sizeof($clean) != 0) {
if(sizeof($clean) == 1) {
$clean = $clean[0];
}
$this->part('excess', array(), $this->excess_raw, $clean);
}
return $this->parts;
}
private function escape_regex($subject) {
return preg_replace("/[\-\[\]{}()*+?.,\\\^$|#\s]/", "\\\\$&", $subject);
}
private function part($name, $match, $raw, $clean) {
# The main core instructuions
$this->parts[$name] = $clean;
if(sizeof($match) > 0) {
# The instructions for extracting title
$index = strpos($this->torrent['name'], $match[0]);
if($index == 0) {
$this->start = strlen($match[0]);
}
elseif(is_null($this->end) || $index < $this->end) {
$this->end = $index;
}
}
if($name != 'excess') {
if($name == 'group') {
$this->group_raw = $raw;
}
if(!is_null($raw)) {
$this->excess_raw = str_replace($raw, '', $this->excess_raw);
}
}
}
private function late($name, $clean) {
if($name == 'group') {
$this->part($name, array(), null, $clean);
}
elseif($name == 'episodeName') {
$clean = preg_replace("/[\._]/", ' ', $clean);
$clean = preg_replace("/_+$/", '', $clean);
$this->part($name, array(), null, trim($clean));
}
}
}

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Download\Downloader;
use App\Base\Service\Broadcaster;
use App\Base\Service\MediaFiles;
use App\Download\Framework\Entity\Download;
use Doctrine\ORM\EntityManagerInterface;
@@ -19,6 +20,7 @@ class ProcessDownloader implements DownloaderInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private CacheInterface $cache,
private readonly Broadcaster $broadcaster,
) {}
/**
@@ -82,6 +84,7 @@ class ProcessDownloader implements DownloaderInterface
});
if ($downloadEntity->getStatus() !== 'Paused') {
$downloadEntity->setProgress(100);
$this->alertComplete($downloadEntity);
}
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
@@ -105,4 +108,15 @@ class ProcessDownloader implements DownloaderInterface
throw new \Exception("There is no download path for media type: $mediaType");
}
private function alertComplete(Download $download): void
{
if ("tvshows" === $download->getMediaType()) {
$message = '"' . $download->getTitle() . '" - ' . $download->getEpisodeId() . ' has finished downloading.';
} else {
$message = '"' . $download->getTitle() . '" has finished downloading.';
}
$this->broadcaster->alert('Success', $message, sendPush: true);
}
}

View File

@@ -6,7 +6,7 @@ use App\Download\Framework\Repository\DownloadRepository;
use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;

View File

@@ -7,7 +7,7 @@ use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use Symfony\Component\Security\Core\User\UserInterface;
/**

View File

@@ -6,7 +6,7 @@ use App\Base\Service\MediaFiles;
use App\Library\Action\Command\LibrarySearchCommand;
use App\Library\Action\Result\LibrarySearchResult;
use App\Library\Dto\MediaFileDto;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;

View File

@@ -5,12 +5,11 @@ namespace App\Monitor\Action\Handler;
use App\Base\Util\EpisodeId;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
@@ -33,7 +32,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
private Tmdb $tmdb,
private TmdbClient $tmdb,
private DownloadRepository $downloadRepository,
) {}
@@ -43,8 +42,24 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
$episodeData = $this->tmdb->tvEpisodeDetails($monitor->getTmdbId(), $monitor->getImdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (null === $episodeData->airDate || "" === $episodeData->airDate) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode does not have an air date, skipping for now');
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'message' => 'No change',
'monitor' => $monitor,
]
);
}
if (null === $monitor->getAirDate()) {
$monitor->setAirDate(Carbon::parse($episodeData->airDate));
}
if (Carbon::createFromTimestamp($episodeData->airDate) > Carbon::today('UTC')) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult(
status: 'OK',

View File

@@ -9,10 +9,10 @@ use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Result\MonitorTvSeasonResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use App\Base\Util\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -26,7 +26,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private Tmdb $tmdb,
private TmdbClient $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {}
@@ -50,7 +50,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
// Compare against list from TMDB
$episodesInSeason = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())->episodes[$monitor->getSeason()]
$this->tmdb->tvshowDetails($monitor->getImdbId())->episodes[$monitor->getSeason()]
)->rekey(fn($episode) => $episode['episode_number']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());

View File

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

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Monitor\Dto;
use Carbon\Carbon;
class UpcomingEpisode
{
public function __construct(
public string $title,
public string $airDate {
get => Carbon::parse($this->airDate)->format('m/d/Y');
},
public string $episodeTitle,
public int $episodeNumber,
) {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
namespace App\Torrentio\Result;
use App\User\Database\CountryLanguages;
use Nihilarr\PTN;
use App\Base\Util\PTN;
class ResultFactory
{
@@ -18,7 +18,8 @@ class ResultFactory
public static function map(
string $url,
string $title,
string $bingeGroup = "-"
string $bingeGroup = "-",
string $imdbId = "-",
) {
$ptn = (object) (new PTN())->parse($title);
return new TorrentioResult(
@@ -40,7 +41,8 @@ class ResultFactory
self::setLanguages($title),
self::setLanguageFlags($title),
false,
uniqid()
uniqid(),
$imdbId,
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\User\Database;
class CodecList
{
public static $codecs = [
'-',
'h264',
'h265/HEVC',
];
@@ -16,9 +17,13 @@ class CodecList
public static function asSelectOptions(): array
{
return [
'h264' => 'h264',
'h265/HEVC' => 'h265',
];
$result = [];
foreach (static::$codecs as $codec) {
$result[$codec] = $codec;
}
$result['h265/HEVC'] = 'h265';
return $result;
}
}

View File

@@ -26,7 +26,7 @@ class ProviderList
public static function asSelectOptions(): array
{
$result = [];
$result = ['-' => '-'];
foreach (static::$providers as $provider) {
$result[$provider] = $provider;
}

View File

@@ -102,7 +102,7 @@ class QualityList
public static function asSelectOptions(): array
{
$result = ['n/a' => null];
$result = ['n/a' => null, '-' => '-'];
foreach (array_keys(static::$qualities) as $quality) {
$result[$quality] = $quality;
}

View File

@@ -18,7 +18,7 @@ class ResolutionList
public static function asSelectOptions(): array
{
$result = [];
$result = ['-' => '-'];
foreach (static::$resolutions as $resolution) {
$result[$resolution] = $resolution;
}

View File

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

View File

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

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