Compare commits

..

42 Commits

Author SHA1 Message Date
4506306377 fix: styles on monitoring and search buttons 2025-05-09 16:25:35 -05:00
4287b52bd4 fix: a few bugs after moving code to own directory 2025-05-09 16:03:01 -05:00
3724bcbb16 fix: moves monitor logic into own directory 2025-05-09 15:03:42 -05:00
6c2cd7510f fix: calls clearCache phing target 2025-05-09 12:33:32 -05:00
98bf8d2880 fix: uses default image in episode results if image is missing, reduces cache life to next hour, clears cache during build 2025-05-09 12:30:56 -05:00
20ade478b1 feat: adds episode air date to results 2025-05-08 23:47:08 -05:00
4eed5fef78 feat: deploys monitor container 2025-05-08 22:57:51 -05:00
5ff9842eaa wip-feat: adds functionality to Monitor button 2025-05-08 22:48:25 -05:00
b93da3df1d fix: MonitorDispatcher runs evey 10 mins 2025-05-07 22:41:19 -05:00
fe0ab2ef5a fix: missing status check in query 2025-05-07 22:40:19 -05:00
25ff3e726d wip-feat: working tv season/episode monitor 2025-05-07 22:13:38 -05:00
527adb73c1 wip-feat: dispatches monitor commands for episodes, seasons, & shows 2025-05-06 00:00:45 -05:00
9166b4bbc8 feat: movie monitoring 2025-05-03 23:55:31 -05:00
5688b3a0df feat: button to add movie monitor 2025-05-03 11:53:23 -05:00
babcb00440 feat: movie download monitor 2025-05-03 09:34:40 -05:00
993b34d668 patch: login/register styles 2025-05-01 23:09:18 -05:00
73b3e5179a patch: active/inactive styles on navbar 2025-05-01 23:01:15 -05:00
d3176baff2 patch: fixes missing null check 2025-05-01 22:35:54 -05:00
cc77cccf0b patch: copies .env.properties instead of passing each phing var 2025-05-01 22:04:24 -05:00
b0c10a028a patch: caches torrentio movie results 2025-05-01 21:58:02 -05:00
12bf90a2b4 patch: adds full page caching to TMBD & torrentio results 2025-05-01 21:46:14 -05:00
687b5ed873 Merge branch 'main' into dev-redis 2025-05-01 20:41:26 -05:00
e5f0f358b7 fix: adds redis phing var 2025-05-01 20:21:30 -05:00
fd84648100 patch: sets default download progress to 0, orders active downloads ASC 2025-05-01 16:37:08 -05:00
f3285ba60c patch: fixes extra ajax call on movie options page 2025-05-01 16:35:12 -05:00
4f6f8f43f1 wip: redis integration 2025-05-01 16:34:30 -05:00
b23d8a2ba3 fix: prefills provider preference on filter 2025-04-30 22:23:42 -05:00
bfd5f53d67 fix: multiple options being pre-selected after filter change 2025-04-30 22:03:19 -05:00
f10168a1a7 fix: language filter 2025-04-30 21:39:41 -05:00
8970ca0f8f fix: deploys replica app container 2025-04-30 18:51:09 -05:00
994bd775ea fix: stores session in DB 2025-04-30 18:13:19 -05:00
d0e7941809 fix: chanaged wrong service last commit 2025-04-30 17:31:17 -05:00
a28f0d9369 fix: only deploys 1 replica webapp until a better session handling method is implemented 2025-04-30 17:15:41 -05:00
964cdca151 fix: adds migrateDb to build.xml 2025-04-30 16:00:20 -05:00
b59069551a fix: download options filter uses user preferences 2025-04-30 15:53:10 -05:00
3971cf3260 wip: filter twig component pre-select options 2025-04-29 23:40:27 -05:00
8a1a89f17d wip-feat: populates filter from api options 2025-04-29 22:10:47 -05:00
c3eaf109e3 feat: stores user's media preferences 2025-04-29 16:17:40 -05:00
0225bead60 fix: displays user's name & email in left footer 2025-04-28 21:49:54 -05:00
1b1feaebec wip-feat: user, login/logout, authentication/authorization 2025-04-28 21:45:12 -05:00
7045116b56 wip: adds preference & preference_option tables 2025-04-28 08:50:51 -05:00
883442225f fix: broken search button 2025-04-27 22:11:09 -05:00
106 changed files with 4649 additions and 488 deletions

View File

@@ -5,3 +5,4 @@ DOWNLOAD_DIR=./movies
MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://dev.caldwell.digital/hub/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MONITOR_FREQUENCY="* * * * *"

View File

@@ -1,3 +1,4 @@
APP_ENV=%%app_env%%
APP_SECRET="%%app_secret%%"
DATABASE_URL="%%db_url%%"
DOWNLOAD_DIR=%%download_dir%%
@@ -8,3 +9,4 @@ MERCURE_PUBLIC_URL=%%mercure_public_url%%
MERCURE_JWT_SECRET="%%mercure_jwt_secret%%"
JELLYFIN_URL=%%jellyfin_url%%
JELLYFIN_TOKEN=%%jellyfin_token%%
REDIS_HOST="%%redis_host%%"

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

View File

@@ -0,0 +1,93 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['button', 'options']
static outlets = ['result-filter']
static values = {
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)
}
toggle() {
this.optionsTarget.classList.toggle('hidden');
}
async monitorSeries() {
await this.makeMonitor({
tmdbId: this.tmdbIdValue,
imdbId: this.imdbIdValue,
title: this.titleValue,
monitorType: 'tvshows',
});
}
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',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(body)
});
return await response.json();
}
}

View File

@@ -0,0 +1,24 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static values = {
title: String,
tmdbId: String,
imdbId: String,
mediaType: String,
}
addMovieMonitor() {
console.log(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
fetch(`/monitor/movies/${this.tmdbIdValue}/${this.imdbIdValue}/${encodeURI(this.titleValue)}`)
.then(res => res.json())
.then(json => {
console.log(json)
})
}
}

View File

@@ -6,6 +6,9 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -15,13 +18,15 @@ export default class extends Controller {
static targets = ['list']
options = []
optionsLoaded = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (this.options.length === 0) {
if (false === this.optionsLoaded) {
this.optionsLoaded = true;
await fetch(`/torrentio/movies/${this.tmdbIdValue}/${this.imdbIdValue}`)
.then(res => res.text())
.then(response => {
@@ -37,7 +42,54 @@ export default class extends Controller {
return true;
}
listTargetConnected(target) {
// console.log(target);
async filter(activeFilter) {
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
});
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
inactiveStyles = "block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-orange-500 hover:bg-opacity-80";
activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50";
connect() {
console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles;
if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) {
link.className = this.activeStyles;
}
});
}
setActive() {
}
}

View File

@@ -27,10 +27,11 @@ export default class extends Controller {
'episodes': Array,
}
connect() {
async connect() {
if (this.mediaTypeValue === "tvshows") {
this.activeFilter['season'] = 1;
}
await this.filter();
}
async movieResultsOutletConnected(outlet) {
@@ -49,6 +50,7 @@ export default class extends Controller {
this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset);
})
await this.filter();
}
addLanguages(option, props) {
@@ -59,9 +61,22 @@ export default class extends Controller {
}
});
this.languageTarget.innerHTML = '<option value="">n/a</option>';
const preferred = this.languageTarget.dataset.preferred;
if (preferred) {
this.languageTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.languageTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.languageTarget.innerHTML = '<option value="">n/a</option>';
}
this.languageTarget.innerHTML += this.languages.sort()
.map((language) => '<option value="'+language+'">'+language+'</option>')
.map((language) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === language) {
return;
}
return '<option value="'+language+'">'+language+'</option>';
})
.join();
}
@@ -70,9 +85,22 @@ export default class extends Controller {
this.providers.push(props['provider']);
}
this.providerTarget.innerHTML = '<option value="">n/a</option>';
const preferred = this.providerTarget.dataset.preferred;
if (preferred) {
this.providerTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.providerTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.providerTarget.innerHTML = '<option value="">n/a</option>';
}
this.providerTarget.innerHTML += this.providers.sort()
.map((provider) => '<option value="'+provider+'">'+provider+'</option>')
.map((provider) => {
const preferred = this.languageTarget.dataset.preferred;
if (preferred === provider) {
return;
}
return '<option value="' + provider + '">' + provider + '</option>'
})
.join();
}
@@ -90,79 +118,13 @@ export default class extends Controller {
if ("movies" === this.mediaTypeValue) {
results = this.movieResultsOutlets;
await results.forEach((list) => list.filter(this.activeFilter));
} else if ("tvshows" === this.mediaTypeValue) {
results = this.tvResultsOutlets;
this.activeFilter.season = this.seasonTarget.value;
await results.forEach((list) => list.filter(this.activeFilter, currentSeason, this.seasonTarget.value));
}
const filterOperation = async (resultList, currentSeason) => {
if ("tvshows" === this.mediaTypeValue && currentSeason !== this.activeFilter['season']) {
if (resultList.seasonValue === this.seasonTarget.value) {
await resultList.setActive();
} else {
resultList.setInActive();
}
}
if (false === resultList.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
resultList.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
for (let [key, value] of Object.entries(this.activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
if ("tvshows" === this.mediaTypeValue) {
resultList.countTarget.innerText = count;
}
});
}
await results.forEach((list) => filterOperation(list, currentSeason));
}
uncheckSelectAllBtn() {

View File

@@ -6,6 +6,9 @@ import { Controller } from '@hotwired/stimulus';
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
H264_CODECS = ['h264', 'h.264', 'x264']
H265_CODECS = ['h265', 'h.265', 'x265', 'hevc']
static values = {
title: String,
tmdbId: String,
@@ -26,7 +29,8 @@ export default class extends Controller {
}
async setOptions() {
if (true === this.activeValue) {
if (true === this.activeValue && this.optionsLoaded === false) {
this.optionsLoaded = true;
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
@@ -38,12 +42,18 @@ export default class extends Controller {
} else {
this.episodeSelectorTarget.disabled = true;
}
this.optionsLoaded = true;
this.loadingIconOutlet.increaseCount();
});
}
}
//
// async clearCache() {
// await fetch(`/torrentio/tvshows/clear/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
// .then(res => res.text())
// .then(response => {});
// }
async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');
@@ -52,9 +62,11 @@ export default class extends Controller {
}
}
setInActive() {
async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
@@ -88,4 +100,69 @@ export default class extends Controller {
}
})
}
async filter(activeFilter, currentSeason, newSeason) {
if (currentSeason !== activeFilter['season']) {
if (this.seasonValue === newSeason) {
await this.setActive();
} else {
await this.setInActive();
}
}
if (false === this.isActive()) {
return;
}
let firstIncluded = true;
let count = 0;
let selectedCount = 0;
this.options.forEach((option) => {
const props = {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
for (let [key, value] of Object.entries(activeFilter)) {
if (value === "" || key === "season") {
continue;
}
if (key === "codec" && value === "h264") {
if (!this.H264_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "codec" && value === "h265") {
if (!this.H265_CODECS.includes(props[key].toLowerCase())) {
include = false;
}
} else if (key === "language") {
if (!props["languages"].includes(value)) {
include = false;
}
} else if (props[key] !== value) {
include = false;
}
}
if (false === include) {
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;
selectedCount = selectedCount + 1;
option.querySelector('input[type="checkbox"]').checked = true;
firstIncluded = false;
} else {
count = count + 1;
}
this.countTarget.innerText = count;
});
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" color="currentColor"><path d="M17.201 2H6.8c-1.458 0-2.737.985-2.795 2.404c-.074 1.785 1.182 2.97 2.5 4.083c1.825 1.54 2.737 2.31 2.832 3.284q.023.229 0 .458c-.095.975-1.007 1.744-2.832 3.284c-1.355 1.143-2.578 2.207-2.5 4.083C4.062 21.016 5.34 22 6.799 22H17.2c1.458 0 2.737-.985 2.796-2.404c.046-1.13-.373-2.254-1.262-3.036c-.405-.357-.826-.698-1.24-1.047c-1.824-1.54-2.736-2.31-2.831-3.284a2.3 2.3 0 0 1 0-.458c.095-.975 1.008-1.744 2.832-3.284c1.34-1.131 2.577-2.229 2.5-4.083C19.939 2.984 18.66 2 17.202 2"/><path d="M9 21.638c0-.442 0-.663.088-.856a1 1 0 0 1 .046-.09c.107-.183.288-.312.65-.571c1.006-.719 1.51-1.078 2.081-1.116q.135-.009.27 0c.572.038 1.075.397 2.08 1.116c.363.259.544.388.651.571q.026.045.046.09c.088.193.088.414.088.856V22H9z"/></g></svg>

After

Width:  |  Height:  |  Size: 928 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z"/></svg>

After

Width:  |  Height:  |  Size: 224 B

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M4 22a8 8 0 1 1 16 0h-2a6 6 0 0 0-12 0zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6s6 2.685 6 6s-2.685 6-6 6m0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4"/></svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project name="Caldwell Digital Symfony Template" default="build">
<!-- build dev for dev envs -->
<target name="build" depends="setEnv,composer,compileAssets" />
<target name="build" depends="setEnv,composer,compileAssets,migrateDb,clearCache" />
<target name="composer" description="Run composer">
<exec executable="composer">
@@ -9,6 +9,11 @@
</exec>
</target>
<target name="setEnv" description="Set the database configuration">
<copy file="${project.basedir}/.env.properties" tofile="${project.basedir}/.env.local" overwrite="true">
</copy>
</target>
<target name="compileAssets" description="Run composer">
<exec executable="php">
<arg value="bin/console" />
@@ -20,22 +25,19 @@
</exec>
</target>
<target name="setEnv" description="Set the database configuration">
<copy file="${project.basedir}/.env.dist" tofile="${project.basedir}/.env.local" overwrite="true">
<filterchain>
<replacetokens begintoken="%%" endtoken="%%">
<token key="app_secret" value="${APP_SECRET}" />
<token key="db_url" value="${DATABASE_URL}" />
<token key="download_dir" value="${DOWNLOAD_DIR}" />
<token key="rd_key" value="${REAL_DEBRID_KEY}" />
<token key="tmdb_api" value="${TMDB_API}" />
<token key="mercure_url" value="${MERCURE_URL}" />
<token key="mercure_public_url" value="${MERCURE_PUBLIC_URL}" />
<token key="mercure_jwt_secret" value="${MERCURE_JWT_SECRET}" />
<token key="jellyfin_url" value="${JELLYFIN_URL}" />
<token key="jellyfin_token" value="${JELLYFIN_TOKEN}" />
</replacetokens>
</filterchain>
</copy>
<target name="migrateDb" description="Migrate the database">
<exec executable="php">
<arg value="bin/console" />
<arg value="--no-interaction" />
<arg value="doctrine:migrations:migrate" />
</exec>
</target>
<target name="clearCache" description="Clear the application cache">
<exec executable="php">
<arg value="bin/console" />
<arg value="cache:pool:clear" />
<arg value="cache.app" />
</exec>
</target>
</project>

View File

@@ -12,6 +12,13 @@ services:
- $PWD/bash/caddy:/etc/caddy
- $PWD/bash/certs:/etc/ssl
redis:
image: redis:latest
volumes:
- redis_data:/data
command: redis-server --maxmemory 512MB
restart: unless-stopped
php:
build: .
volumes:
@@ -22,7 +29,14 @@ services:
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
command: php ./bin/console messenger:consume async -vv --time-limit=3600
scheduler:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
@@ -63,3 +77,4 @@ volumes:
mysql:
mercure_data:
mercure_config:
redis_data:

View File

@@ -8,24 +8,31 @@
"ext-ctype": "*",
"ext-iconv": "*",
"1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"symfony/asset": "7.2.*",
"symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*",
"symfony/filesystem": "7.2.*",
"symfony/finder": "7.2.*",
"symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*",
"symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*",
@@ -91,6 +98,9 @@
}
},
"require-dev": {
"symfony/maker-bundle": "^1.62"
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*"
}
}

1061
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,4 +16,5 @@ return [
Symfony\UX\LiveComponent\LiveComponentBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
];

View File

@@ -8,8 +8,8 @@ framework:
# Other options include:
# Redis
#app: cache.adapter.redis
#default_redis_provider: redis://localhost
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_HOST)%'
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
#app: cache.adapter.apcu

View File

@@ -18,18 +18,24 @@ doctrine:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
# App:
# type: attribute
# is_bundle: false
# dir: '%kernel.project_dir%/src/Entity'
# prefix: 'App\Entity'
# alias: App
Download:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Download/Framework/Entity'
prefix: 'App\Download\Framework\Entity'
alias: Download
User:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/User/Framework/Entity'
prefix: 'App\User\Framework\Entity'
alias: User
Monitor:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Monitor/Framework/Entity'
prefix: 'App\Monitor\Framework\Entity'
alias: Download
controller_resolver:
auto_mapping: false

View File

@@ -2,8 +2,16 @@
framework:
secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it.
session: true
serializer:
default_context:
enable_max_depth: true
trusted_proxies: 'private_ranges'
# trust *all* "X-Forwarded-*" headers
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
#esi: true
#fragments: true

View File

@@ -1,11 +1,19 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
async: '%env(MESSENGER_TRANSPORT_DSN)%'
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 1
multiplier: 1
failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default
@@ -17,6 +25,10 @@ framework:
# Route your messages to the transports
# 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async
'App\Monitor\Action\Command\MonitorMovieCommand': async
# when@test:
# framework:

View File

@@ -5,13 +5,26 @@ security:
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\User\Framework\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
form_login:
login_path: app_login
check_path: app_login
enable_csrf: true
logout:
path: app_logout
# where to redirect after logout
# target: app_any_route
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall
@@ -22,8 +35,9 @@ 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: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN,
when@test:
security:

View File

@@ -0,0 +1,7 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
fallbacks:
- en
providers:

View File

@@ -0,0 +1,11 @@
when@dev:
web_profiler:
toolbar: true
framework:
profiler:
collect_serializer_data: true
when@test:
framework:
profiler: { collect: false }

View File

@@ -6,3 +6,18 @@ controllersIndex:
defaults:
schemes: [ 'https' ]
controllersUser:
resource:
path: ../src/User/Framework/Controller
namespace: App\User\Framework\Controller
type: attribute
defaults:
schemes: ['https']
controllersMonitor:
resource:
path: ../src/Monitor/Framework/Controller
namespace: App\Monitor\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -0,0 +1,8 @@
when@dev:
web_profiler_wdt:
resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml'
prefix: /_wdt
web_profiler_profiler:
resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml'
prefix: /_profiler

View File

@@ -4,6 +4,10 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
media.default_movies_dir: movies
media.default_tvshows_dir: tvshows
media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%'
media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%'
services:
# default configuration for services in *this* file
@@ -23,3 +27,7 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'

View File

@@ -14,6 +14,12 @@ services:
deploy:
replicas: 2
scheduler:
image: registry.caldwell.digital/home/torsearch/app:${TAG}
volumes:
- /mnt/media/downloads:/var/download
command: php ./bin/console messenger:consume scheduler_monitor -vv --time-limit=3600
mercure:
image: dunglas/mercure
restart: unless-stopped

View File

@@ -0,0 +1,46 @@
<?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 Version20250428133608 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE preference (id VARCHAR(255) NOT NULL, name VARCHAR(255) DEFAULT NULL, description VARCHAR(255) DEFAULT NULL, enabled TINYINT(1) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, value VARCHAR(255) DEFAULT NULL, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference_option DROP FOREIGN KEY FK_607C52FD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference_option
SQL);
}
}

View File

@@ -0,0 +1,83 @@
<?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 Version20250428140450 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL COMMENT '(DC2Type:json)', password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE episode DROP FOREIGN KEY FK_DDAA1CDAACB7A4A
SQL);
$this->addSql(<<<'SQL'
DROP TABLE processed_messages
SQL);
$this->addSql(<<<'SQL'
DROP TABLE episode
SQL);
$this->addSql(<<<'SQL'
DROP TABLE series
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE messenger_messages CHANGE id id BIGINT AUTO_INCREMENT NOT NULL
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE processed_messages (id INT AUTO_INCREMENT NOT NULL, run_id INT NOT NULL, attempt SMALLINT NOT NULL, message_type VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, description VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, dispatched_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', received_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', finished_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', wait_time BIGINT NOT NULL, handle_time BIGINT NOT NULL, memory_usage INT NOT NULL, transport VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tags VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, failure_type VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, failure_message LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, results JSON DEFAULT NULL COMMENT '(DC2Type:json)', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE episode (id INT AUTO_INCREMENT NOT NULL, series_id_id INT DEFAULT NULL, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, tvdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, year VARCHAR(5) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, poster VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, season VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, episode VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, episode_code VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, download_directory VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, INDEX IDX_DDAA1CDAACB7A4A (series_id_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE series (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tvdb_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, year VARCHAR(5) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, poster VARCHAR(500) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, directory VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, number_seasons INT DEFAULT NULL, number_episodes INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE episode ADD CONSTRAINT FK_DDAA1CDAACB7A4A FOREIGN KEY (series_id_id) REFERENCES series (id)
SQL);
$this->addSql(<<<'SQL'
DROP TABLE user
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE messenger_messages CHANGE id id INT AUTO_INCREMENT NOT NULL
SQL);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250429020903 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user ADD name VARCHAR(255) DEFAULT NULL
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user DROP name
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 Version20250429032311 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE user_preference (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, preference_id VARCHAR(255) NOT NULL, preference_value VARCHAR(255) DEFAULT NULL, INDEX IDX_FA0E76BFA76ED395 (user_id), INDEX IDX_FA0E76BFD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFA76ED395
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE user_preference
SQL);
}
}

View File

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

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250503034641 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE movie_monitor
SQL);
}
}

View File

@@ -0,0 +1,53 @@
<?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 Version20250505211458 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', monitor_type VARCHAR(255) NOT NULL, season INT DEFAULT NULL, episode INT DEFAULT NULL, INDEX IDX_E1159985A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD CONSTRAINT FK_E1159985A76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE movie_monitor
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tmdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, status VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP FOREIGN KEY FK_E1159985A76ED395
SQL);
$this->addSql(<<<'SQL'
DROP TABLE monitor
SQL);
}
}

8
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

View File

@@ -6,9 +6,12 @@ use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler;
use App\Search\Action\Input\GetMediaInfoInput;
use App\Search\Action\Input\SearchInput;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class SearchController extends AbstractController
{
@@ -30,20 +33,25 @@ final class SearchController extends AbstractController
#[Route('/result/{mediaType}/{tmdbId}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $getDownloadOptionsInput,
GetMediaInfoInput $input,
CacheInterface $cache
): Response {
$result = $this->getMediaInfoHandler->handle($getDownloadOptionsInput->toCommand());
$cacheId = sprintf("page.%s.%s", $input->mediaType, $input->tmdbId);
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
'resolution' => '',
'codec' => '',
'provider' => '',
'language' => '',
'season' => 1,
'episode' => ''
]
]);
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$result = $this->getMediaInfoHandler->handle($input->toCommand());
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [
'resolution' => '',
'codec' => '',
'provider' => '',
'language' => '',
'season' => 1,
'episode' => ''
]
]);
// });
}
}

View File

@@ -6,34 +6,89 @@ use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class TorrentioController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly HubInterface $hub,
private readonly \Twig\Environment $renderer,
) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input): Response
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input): Response
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$cache->delete($cacheId);
$this->hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => 'Torrentio cache Cleared.',
])
));
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Download\Framework\MessageHandler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(handles: DownloadMediaCommand::class)]
class DownloadMediaMessageHandler
{
public function __construct(
private DownloadMediaHandler $downloadMediaHandler,
) {}
public function __invoke(DownloadMediaCommand $command)
{
$this->downloadMediaHandler->handle($command);
}
}

View File

@@ -41,7 +41,7 @@ class DownloadRepository extends ServiceEntityRepository
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->orderBy('d.id', 'DESC')
->orderBy('d.id', 'ASC')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
@@ -66,6 +66,7 @@ class DownloadRepository extends ServiceEntityRepository
->setImdbId($imdbId)
->setMediaType($mediaType)
->setBatchId($batchId)
->setProgress(0)
->setStatus($status);
$this->getEntityManager()->persist($download);

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class AddMonitorCommand implements CommandInterface
{
public function __construct(
public string $userEmail,
public string $title,
public string $imdbId,
public string $tmdbId,
public string $monitorType,
public ?int $season,
public ?int $episode,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorMovieCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvEpisodeCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvSeasonCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Monitor\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvShowCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\AddMonitorCommand;
use App\Monitor\Action\Result\AddMonitorResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<AddMonitorCommand> */
readonly class AddMonitorHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$user = $this->userRepository->findOneBy(['email' => $command->userEmail]);
$monitor = (new Monitor())
->setUser($user)
->setTmdbId($command->tmdbId)
->setImdbId($command->imdbId)
->setTitle($command->title)
->setMonitorType($command->monitorType)
->setSeason($command->season)
->setEpisode($command->episode)
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->movieMonitorRepository->getEntityManager()->persist($monitor);
$this->movieMonitorRepository->getEntityManager()->flush();
return new AddMonitorResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Monitor\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorMovieHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $movieMonitorRepository,
private GetMovieOptionsHandler $getMovieOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options');
$results = $this->getMovieOptionsHandler->handle(
new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId())
);
$this->logger->info('> [MonitorMovieHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorMovieHandler] 1 result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'movies',
$monitor->getImdbId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeIMmutable());
} else {
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvEpisodeHandler implements HandlerInterface
{
public function __construct(
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$this->logger->info('> [MonitorTvEpisodeHandler] Searching for "' . $monitor->getTitle() . '" season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' download options');
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$monitor->getTmdbId(),
$monitor->getImdbId(),
$monitor->getSeason(),
$monitor->getEpisode()
)
);
$this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) {
$this->logger->info('> [MonitorTvEpisodeHandler] 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'tvshows',
$monitor->getImdbId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());
} else {
$this->logger->info('> [MonitorTvEpisodeHandler] 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Active');
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvSeasonHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvSeasonHandler] Executing MonitorTvSeasonHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode))
->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
// Compare against list from TMDB
$episodesInSeason = Map::from(
$this->tmdb->tvDetails($monitor->getTmdbId())
->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());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInSeason as $episode) {
$monitorCheck = $this->monitorRepository->findOneBy([
'imdbId' => $monitor->getImdbId(),
'title' => $monitor->getTitle(),
'monitorType' => 'tvepisode',
'season' => $monitor->getSeason(),
'episode' => $episode['episode_number'],
'status' => ['New', 'Active', 'In Progress']
]);
$this->logger->info('> [MonitorTvSeasonHandler] Monitor exists for season ' . $monitor->getSeason() . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (null !== $monitorCheck ? 'YES' : 'NO'));
if (!array_key_exists($episode['episode_number'], $downloadedEpisodes->toArray())
&& null === $monitorCheck
) {
$episodeMonitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvepisode')
->setSeason($monitor->getSeason())
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvSeasonHandler] Dispatching MonitorTvEpisodeCommand for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorMovieResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Monitor\Action\Handler;
use Aimeos\Map;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
readonly class MonitorTvShowHandler implements HandlerInterface
{
public function __construct(
private MonitorRepository $monitorRepository,
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private MessageBusInterface $bus,
private LoggerInterface $logger,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler');
$monitor = $this->monitorRepository->find($command->monitorId);
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($monitor->getTitle())
->map(fn($episode) => (object) (new PTN())->parse($episode));
$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->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
$episodeAlreadyDownloaded = $downloadedEpisodes->find(
fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number']
);
$episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded);
if (false === $episodeAlreadyDownloaded) {
$monitor = (new Monitor())
->setUser($monitor->getUser())
->setTmdbId($monitor->getTmdbId())
->setImdbId($monitor->getImdbId())
->setTitle($monitor->getTitle())
->setMonitorType('tvshow')
->setSeason($episode['season_number'])
->setEpisode($episode['episode_number'])
->setCreatedAt(new DateTimeImmutable())
->setSearchCount(0)
->setStatus('New');
$this->monitorRepository->getEntityManager()->persist($monitor);
$this->monitorRepository->getEntityManager()->flush();
$command = new MonitorTvEpisodeCommand($monitor->getId());
$this->bus->dispatch($command);
$this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle());
}
}
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$monitor->setStatus('Complete');
$this->entityManager->flush();
return new MonitorTvEpisodeResult(
status: 'OK',
result: [
'monitor' => $monitor,
]
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Monitor\Action\Input;
use App\Monitor\Action\Command\AddMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class AddMonitorInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public string $userEmail,
#[SourceRequest('tmdbId')]
public string $tmdbId,
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('monitorType')]
public string $monitorType,
#[SourceRequest('season', nullify: true)]
public ?int $season,
#[SourceRequest('episode', nullify: true)]
public ?int $episode,
) {}
public function toCommand(): CommandInterface
{
return new AddMonitorCommand(
$this->userEmail,
$this->title,
$this->imdbId,
$this->tmdbId,
$this->monitorType,
$this->season,
$this->episode,
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Monitor\Action\Input;
use App\Monitor\Action\Command\MonitorMovieCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class MonitorMovieInput implements InputInterface
{
public function __construct(
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('imdbId')]
public string $imdbId,
) {}
public function toCommand(): CommandInterface
{
return new MonitorMovieCommand($this->tmdbId, $this->imdbId);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class AddMonitorResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorMovieResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Monitor\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorTvEpisodeResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Monitor\Framework\Controller;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
class ApiController extends AbstractController
{
public function __construct(
#[Autowire(service: 'twig')]
private readonly Environment $renderer,
private readonly HubInterface $hub,
) {}
#[Route('/api/monitor', name: 'api_monitor', methods: ['POST'])]
public function addMonitor(
AddMonitorInput $input,
AddMonitorHandler $handler,
HubInterface $hub,
) {
$response = $handler->handle($input->toCommand());
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$input->title}",
])
));
return $this->json([
'status' => 200,
'message' => $response
]);
}
}

View File

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Monitor\Framework\Entity;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
#[ORM\Entity(repositoryClass: MonitorRepository::class)]
class Monitor
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'yes')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 255)]
private ?string $imdbId = null;
#[ORM\Column(length: 255)]
private ?string $tmdbId = null;
#[ORM\Column(length: 255)]
private ?string $monitorType = null;
#[ORM\Column(nullable: true)]
private ?int $season = null;
#[ORM\Column(nullable: true)]
private ?int $episode = null;
#[ORM\Column(length: 255)]
private ?string $status = null;
#[ORM\Column(nullable: true)]
private ?int $searchCount = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $downloadedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getImdbId(): ?string
{
return $this->imdbId;
}
public function setImdbId(string $imdbId): static
{
$this->imdbId = $imdbId;
return $this;
}
public function getTmdbId(): ?string
{
return $this->tmdbId;
}
public function setTmdbId(string $tmdbId): static
{
$this->tmdbId = $tmdbId;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getSearchCount(): ?int
{
return $this->searchCount;
}
public function setSearchCount(?int $searchCount): static
{
$this->searchCount = $searchCount;
return $this;
}
public function getLastSearch(): ?\DateTimeInterface
{
return $this->lastSearch;
}
public function setLastSearch(?\DateTimeInterface $lastSearch): static
{
$this->lastSearch = $lastSearch;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getDownloadedAt(): ?\DateTimeImmutable
{
return $this->downloadedAt;
}
public function setDownloadedAt(?\DateTimeImmutable $downloadedAt): static
{
$this->downloadedAt = $downloadedAt;
return $this;
}
public function getMonitorType(): ?string
{
return $this->monitorType;
}
public function setMonitorType(string $monitorType): static
{
$this->monitorType = $monitorType;
return $this;
}
public function getSeason(): ?int
{
return $this->season;
}
public function setSeason(?int $season): static
{
$this->season = $season;
return $this;
}
public function getEpisode(): ?int
{
return $this->episode;
}
public function setEpisode(?int $episode): static
{
$this->episode = $episode;
return $this;
}
}

View File

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Monitor\Framework\Repository;
use App\Monitor\Framework\Entity\Monitor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Monitor>
*/
class MonitorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Monitor::class);
}
// /**
// * @return MovieMonitor[] Returns an array of MovieMonitor objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('m.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?MovieMonitor
// {
// return $this->createQueryBuilder('m')
// ->andWhere('m.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Monitor\Framework\Scheduler;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('*/10 * * * *', schedule: 'monitor')]
class MonitorDispatcher
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly MonitorRepository $monitorRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$monitorHandlers = [
'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class,
'tvseason' => MonitorTvSeasonCommand::class,
'tvshow' => MonitorTvShowCommand::class,
];
$monitors = $this->monitorRepository->findBy(['status' => ['New', 'Active']]);
foreach ($monitors as $monitor) {
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$command = $monitorHandlers[$monitor->getMonitorType()];
$this->logger->info('[MonitorDispatcher] Dispatching ' . $command . ' for ' . $monitor->getTitle());
$this->bus->dispatch(new $command($monitor->getId()));
}
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Monitor\Service;
use Aimeos\Map;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
class MediaFiles
{
private Finder $finder;
private string $moviesPath;
private string $tvShowsPath;
private Filesystem $filesystem;
public function __construct(
#[Autowire(param: 'media.movies_path')]
string $moviesPath,
#[Autowire(param: 'media.tvshows_path')]
string $tvShowsPath,
Filesystem $filesystem,
) {
$this->finder = new Finder();
$this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem;
}
public function getMoviesPath(): string
{
return $this->moviesPath;
}
public function getTvShowsPath(): string
{
return $this->tvShowsPath;
}
public function getMovieDirs(): Map
{
$results = [];
foreach ($this->finder->in($this->moviesPath)->directories() as $file) {
$results[] = $file;
}
return Map::from($results);
}
public function getTvShowDirs(): Map
{
$results = [];
foreach ($this->finder->in($this->tvShowsPath)->directories() as $file) {
$results[] = $file;
}
return Map::from($results);
}
public function getEpisodes(string $path, bool $onlyFilenames = true): Map
{
if (!str_starts_with($path, $this->tvShowsPath)) {
$path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path;
}
if (false === $this->filesystem->exists($path)) {
$this->filesystem->mkdir($path);
}
$results = [];
foreach ($this->finder->in($path)->files() as $file) {
if ($onlyFilenames) {
$results[] = $file->getRelativePathname();
} else {
$results[] = $file;
}
}
return Map::from($results);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Monitor\Service;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Result\TorrentioResult;
class MonitorOptionEvaluator
{
/**
* @param Monitor $monitor
* @param TorrentioResult[] $results
* @return TorrentioResult|null
* @throws \Throwable
*/
public function evaluateOptions(Monitor $monitor, array $results): ?TorrentioResult
{
$sizeLow = 000;
$sizeHigh = 4096;
$bestMatches = [];
$matches = [];
$userPreferences = $monitor->getUser()->getUserPreferenceValues();
foreach ($results as $result) {
if (!in_array($userPreferences['language'], $result->languages)) {
continue;
}
if ($result->resolution === $userPreferences['resolution']
&& $result->codec === $userPreferences['codec']
) {
$bestMatches[] = $result;
}
if ($userPreferences['resolution'] === '2160p'
&& $userPreferences['codec'] === $result->codec
&& $result->resolution === '1080p'
) {
$matches[] = $result;
}
if ($userPreferences['codec'] === 'h264'
&& $userPreferences['resolution'] === $result->resolution
&& $result->codec === 'h265'
) {
$matches[] = $result;
}
if (($userPreferences['codec'] === null )
&& ($userPreferences['resolution'] === null )) {
$matches[] = $result;
}
}
$sizeMatches = [];
foreach ($bestMatches as $result) {
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
foreach ($matches as $result) {
$size = 0;
if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else {
$size = (int) trim(str_replace('MB', '', $result->size));
}
if ($size > $sizeLow && $size < $sizeHigh) {
$sizeMatches[] = $result;
}
}
if (!empty($sizeMatches)) {
return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last();
}
return null;
}
}

29
src/Schedule.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App;
use App\Download\Framework\Scheduler\MonitorDispatcher;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Tmdb;
use App\Enum\MediaType;
use App\Tmdb\TmdbResult;
use App\ValueObject\ResultFactory;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -18,12 +17,8 @@ use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\Listener\RequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Model\AbstractModel;
use Tmdb\Model\Image;
use Tmdb\Model\Movie;
use Tmdb\Model\Movie\QueryParameter\AppendToResponse;
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
use Tmdb\Model\Search\SearchQuery\MovieSearchQuery;
use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
@@ -210,55 +205,60 @@ class Tmdb
throw new \Exception("A media type must be set when parsing from an array.");
}
function parseTvShow(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: $posterBasePath . $data['poster_path'],
description: $data['overview'],
year: (new \DateTime($data['first_air_date']))->format('Y'),
mediaType: "tvshows",
episodes: $data['episodes'],
);
}
function parseEpisode(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: $posterBasePath . $data['still_path'],
description: $data['overview'],
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
);
}
function parseMovie(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['title'],
poster: $posterBasePath . $data['poster_path'],
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
);
}
if ($mediaType === 'movie') {
$result = parseMovie($data, self::POSTER_IMG_PATH);
$result = $this->parseMovie($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'tvshow') {
$result = parseTvShow($data, self::POSTER_IMG_PATH);
$result = $this->parseTvShow($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'episode') {
$result = parseEpisode($data, self::POSTER_IMG_PATH);
$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",
);
}
private function parseFromObject($result): TmdbResult
{
$mediaType = $result instanceof Movie ? MediaType::Movie->value : MediaType::TvShow->value;

View File

@@ -13,5 +13,6 @@ class TmdbResult
public ?string $year = "",
public ?string $mediaType = "",
public ?array $episodes = null,
public ?string $episodeAirDate = null,
) {}
}

View File

@@ -6,6 +6,7 @@ use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\MediaResult;
use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -32,7 +33,7 @@ class Torrentio
$cacheKey = "torrentio.{$imdbCode}";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode) {
$item->expiresAt(new \DateTimeImmutable("today 11:59 pm"));
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$response = file_get_contents(str_replace('{imdbCode}', $imdbCode, $this->searchUrl));
return json_decode(
$response,
@@ -67,7 +68,7 @@ class Torrentio
{
$cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
$item->expiresAt(new \DateTimeImmutable("today 11:59 pm"));
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$response = file_get_contents(str_replace('{imdbCode}', "$imdbId:$season:$episode", $this->searchUrl));
return json_decode(
$response,

View File

@@ -3,10 +3,19 @@
namespace App\Torrentio\Result;
use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use Nihilarr\PTN;
class ResultFactory
{
public static $codecMap = [
'h264' => 'h264',
'h265' => 'h265',
'x264' => 'h264',
'x265' => 'h265',
'-' => '-'
];
public static function map(
string $url,
string $title,
@@ -24,7 +33,7 @@ class ResultFactory
$ptn->season ?? "-",
$bingeGroup,
$ptn->resolution ?? "-",
$ptn->codec ?? "-",
self::setCodec($ptn->codec ?? "-"),
$ptn,
substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-",
@@ -89,16 +98,22 @@ class ResultFactory
});
$languages = array_map(function ($flag) {
return CountryCodes::convertFromAbbr(strtoupper(substr($flag['short_name'], strlen('flag-'))));
return CountryLanguages::fromCountryCode(strtoupper(substr($flag['short_name'], strlen('flag-'))));
},
$flags);
if (count($languages) > 0) {
return array_values($languages);
} else {
return ["English"];
return ["English (US)"];
}
}
public static function setCodec(string $codec): string
{
return self::$codecMap[strtolower($codec)] ?? $codec;
}
private static function setEpisode(string $title)
{
$value = [];

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class Filter
{
use DefaultActionTrait;
public array $preferences = [];
public array $userPreferences = [];
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly Security $security,
) {
$this->preferences = Map::from($this->preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->map(fn($element) => $element->getPreferenceOptions()->toArray())
->toArray();
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
->toArray();
}
}

View File

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

View File

@@ -0,0 +1,50 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
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 SaveUserMediaPreferencesHandler 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 SaveUserMediaPreferencesResult($user->getUserPreferences());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\User\Action\Input;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<SaveUserMediaPreferencesInput, SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public mixed $userId,
#[SourceRequest('resolution')]
public string $resolution,
#[SourceRequest('codec')]
public string $codec,
#[SourceRequest('language')]
public string $language,
#[SourceRequest('provider')]
public string $provider,
) {}
public function toCommand(): C
{
return new SaveUserMediaPreferencesCommand(
$this->resolution,
$this->codec,
$this->language,
$this->provider,
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Result;
use Doctrine\Common\Collections\Collection;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface */
class SaveUserMediaPreferencesResult implements ResultInterface
{
public function __construct(
public Collection $userPreferences,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\User\Framework\Controller\Api;
use Aimeos\Map;
use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesApiController extends AbstractController
{
#[Route('/api/preferences/media', name: 'api_preferences_media')]
public function getMediaPreferences(
PreferencesRepository $preferencesRepository,
): Response {
return $this->json(
Map::from($preferencesRepository->findEnabled())
->rekey(fn($element) => $element->getId())
->toArray()
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\User\Framework\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class UserApiController extends AbstractController
{
#[Route('/api/user/preferences/values', name: 'api_user_preferences_values')]
public function getUserPreferenceValues(
Security $security
): Response {
return $this->json(
$security->getUser()->getUserPreferenceValues()
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\User\Framework\Controller\Web;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController
{
#[Route(path: '/login', name: 'app_login')]
public function login(AuthenticationUtils $authenticationUtils): Response
{
// get the login error if there is one
$error = $authenticationUtils->getLastAuthenticationError();
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
return $this->render('user/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
]);
}
#[Route(path: '/logout', name: 'app_logout')]
public function logout(): void
{
throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use Aimeos\Map;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class PreferencesController extends AbstractController
{
public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler,
private readonly Security $security,
) {}
#[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])]
public function mediaPreferences(): Response
{
$enabledPreferences = $this->preferencesRepository->findEnabled();
if ($this->security->getUser()->getUserPreferences()->count() !== count($enabledPreferences)) {
$this->setUserPreferences($this->security->getUser(), $enabledPreferences);
}
$userPreferences = $this->security->getUser()->getUserPreferences()->toArray();
$userPreferences = Map::from($userPreferences)
->rekey(fn($preference) => $preference->getPreference()->getId());
$languages = CountryLanguages::$languages;
sort($languages);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
]
);
}
#[Route('/media/preferences', 'app_save_media_preferences', methods: ['POST'])]
public function saveMediaPreferences(
SaveUserMediaPreferencesInput $input,
): Response
{
$userPreferences = $this->saveUserMediaPreferencesHandler->handle($input->toCommand())->userPreferences;
$userPreferences = Map::from($userPreferences)->rekey(fn($preference) => $preference->getPreference()->getId());
$languages = CountryLanguages::$languages;
sort($languages);
return $this->render(
'user/preferences.html.twig',
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'userPreferences' => $userPreferences->toArray(),
]
);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
$this->preferencesRepository->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\User\Framework\Controller\Web;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Form\RegistrationFormType;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(
Request $request,
UserPasswordHasherInterface $userPasswordHasher,
EntityManagerInterface $entityManager,
PreferencesRepository $preferencesRepository,
): Response {
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var string $plainPassword */
$plainPassword = $form->get('plainPassword')->getData();
// encode the plain password
$user->setPassword($userPasswordHasher->hashPassword($user, $plainPassword));
$entityManager->persist($user);
$entityManager->flush();
$this->setUserPreferences($user, $preferencesRepository->findEnabled());
$preferencesRepository->getEntityManager()->flush();
return $this->redirectToRoute('app_index');
}
return $this->render('user/register.html.twig', [
'registrationForm' => $form,
]);
}
private function setUserPreferences(User $user, array $preferences): void
{
foreach ($preferences as $preference) {
$user->addUserPreference((new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue(null)
);
}
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PreferencesRepository::class)]
class Preference
{
#[ORM\Id]
#[ORM\Column]
private ?string $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $description = null;
#[ORM\Column]
private ?bool $enabled = null;
/**
* @var Collection<int, PreferenceOption>
*/
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
private Collection $preferenceOptions;
public function __construct()
{
$this->preferenceOptions = new ArrayCollection();
}
public function getId(): ?string
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
/**
* @return Collection<int, PreferenceOption>
*/
public function getPreferenceOptions(): Collection
{
return $this->preferenceOptions;
}
public function addPreferenceOption(PreferenceOption $preferenceOption): static
{
if (!$this->preferenceOptions->contains($preferenceOption)) {
$this->preferenceOptions->add($preferenceOption);
$preferenceOption->setPreference($this);
}
return $this;
}
public function removePreferenceOption(PreferenceOption $preferenceOption): static
{
if ($this->preferenceOptions->removeElement($preferenceOption)) {
// set the owning side to null (unless already changed)
if ($preferenceOption->getPreference() === $this) {
$preferenceOption->setPreference(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\PreferenceOptionRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Ignore;
#[ORM\Entity(repositoryClass: PreferenceOptionRepository::class)]
class PreferenceOption
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $value = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'preferenceOptions')]
private ?Preference $preference = null;
#[ORM\Column]
private ?bool $enabled = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): static
{
$this->name = $name;
return $this;
}
public function getValue(): ?string
{
return $this->value;
}
public function setValue(?string $value): static
{
$this->value = $value;
return $this;
}
public function getPreference(): ?Preference
{
return $this->preference;
}
public function setPreference(?Preference $preference): static
{
$this->preference = $preference;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private int $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
private ?string $email;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $name;
#[ORM\Column(type: 'json')]
private array $roles = [];
#[ORM\Column(type: 'string')]
private string $password;
/**
* @var Collection<int, UserPreference>
*/
#[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist', 'remove'])]
private Collection $userPreferences;
/**
* @var Collection<int, Monitor>
*/
#[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes;
public function __construct()
{
$this->userPreferences = new ArrayCollection();
$this->yes = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(?string $name): self
{
$this->name = $name;
return $this;
}
/**
* The public representation of the user (e.g. a username, an email address, etc.)
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* @see UserInterface
*/
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
/**
* @return Collection<int, UserPreference>
*/
public function getUserPreferences(): Collection
{
return $this->userPreferences;
}
public function getUserPreference(string $preferenceName)
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getName() === $preferenceName) {
return $userPreference->getPreference();
}
}
}
public function hasUserPreference(string $preferenceName): bool
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getId() === $preferenceName) {
return true;
}
}
return false;
}
public function updateUserPreference(string $preferenceName, mixed $preferenceValue): static
{
foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getId() === $preferenceName) {
$userPreference->setPreferenceValue($preferenceValue);
}
}
return $this;
}
public function addUserPreference(UserPreference $userPreference): static
{
if (!$this->userPreferences->contains($userPreference)) {
$this->userPreferences->add($userPreference);
$userPreference->setUser($this);
}
return $this;
}
public function removeUserPreference(UserPreference $userPreference): static
{
if ($this->userPreferences->removeElement($userPreference)) {
// set the owning side to null (unless already changed)
if ($userPreference->getUser() === $this) {
$userPreference->setUser(null);
}
}
return $this;
}
public function getUserPreferenceValues()
{
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->map(function (UserPreference $userPreference) {
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider'])) {
return $userPreference->getPreferenceValue();
}
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {
// dd((int) $userPreference->getPreferenceValue(), $preferenceOption->getId(), $preferenceOption->getValue());
if ($preferenceOption->getId() === (int) $userPreference->getPreferenceValue()) {
return $preferenceOption->getValue();
}
}
return null;
})
->toArray();
}
/**
* @return Collection<int, Monitor>
*/
public function getYes(): Collection
{
return $this->yes;
}
public function addYe(Monitor $ye): static
{
if (!$this->yes->contains($ye)) {
$this->yes->add($ye);
$ye->setUser($this);
}
return $this;
}
public function removeYe(Monitor $ye): static
{
if ($this->yes->removeElement($ye)) {
// set the owning side to null (unless already changed)
if ($ye->getUser() === $this) {
$ye->setUser(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\UserPreferenceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserPreferenceRepository::class)]
class UserPreference
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'userPreferences')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne(fetch: 'EAGER')]
#[ORM\JoinColumn(nullable: false)]
private ?Preference $preference = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $preference_value = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getPreference(): ?Preference
{
return $this->preference;
}
public function setPreference(?Preference $preference): static
{
$this->preference = $preference;
return $this;
}
public function getPreferenceValue(): ?string
{
return $this->preference_value;
}
public function setPreferenceValue(?string $preference_value): static
{
$this->preference_value = $preference_value;
return $this;
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\User\Framework\Form;
use App\User\Framework\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email')
->add('name')
->add('plainPassword', PasswordType::class, [
// instead of being set onto the object directly,
// this is read and encoded in the controller
'mapped' => false,
'attr' => ['autocomplete' => 'new-password'],
'constraints' => [
new NotBlank([
'message' => 'Please enter a password',
]),
new Length([
'min' => 6,
'minMessage' => 'Your password should be at least {{ limit }} characters',
// max length allowed by Symfony for security reasons
'max' => 4096,
]),
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\PreferenceOption;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PreferenceOption>
*/
class PreferenceOptionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PreferenceOption::class);
}
// /**
// * @return PreferenceOption[] Returns an array of PreferenceOption objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('p.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?PreferenceOption
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\Preference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Preference>
*/
class PreferencesRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Preference::class);
}
/** @return Preference[] Returns an array of Preferences objects */
public function findEnabled(): array
{
return $this->createQueryBuilder('p')
->andWhere('p.enabled = :val')
->setParameter('val', true)
->getQuery()
->getResult();
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\UserPreference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserPreference>
*/
class UserPreferenceRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserPreference::class);
}
public function findUserPreferences(int $userId): array
{
return $this->findBy(['userId' => $userId]);
}
public function findUserResolution(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'resolution']);
}
public function findUserCodec(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'codec']);
}
public function findUserLanguage(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'language']);
}
public function findUserProvider(int $userId): ?UserPreference
{
return $this->findOneBy(['userId' => $userId, 'preference' => 'resolution']);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
/**
* Used to upgrade (rehash) the user's password automatically over time.
*/
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
{
if (!$user instanceof User) {
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
}
$user->setPassword($newHashedPassword);
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
// /**
// * @return User[] Returns an array of User objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('u.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?User
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Util;
class CountryLanguages
{
public static $languages = [
'English (US)',
'English',
'French',
'German',
'Italian',
'Spanish',
'Portuguese',
'Mandarin',
'Japanese',
'Korean',
'Russian',
'Hindi',
'Arabic',
'Dutch',
'Swedish',
'Norwegian',
'Danish',
'Finnish',
'Polish',
'Turkish',
'Persian',
'Thai',
'Vietnamese',
'Indonesian',
'Urdu',
'Bengali',
'Ukrainian',
'Greek',
'Hebrew',
'Malay',
'Filipino'
];
public static function fromCountryCode(string $countryCode): ?string
{
$countryLanguages = [
'US' => 'English (US)',
'GB' => 'English',
'CA' => 'English', // Note: French is also official in parts of Canada
'FR' => 'French',
'DE' => 'German',
'IT' => 'Italian',
'ES' => 'Spanish',
'MX' => 'Spanish',
'BR' => 'Portuguese',
'PT' => 'Portuguese',
'CN' => 'Mandarin',
'JP' => 'Japanese',
'KR' => 'Korean',
'RU' => 'Russian',
'IN' => 'Hindi', // Note: India has multiple official languages
'SA' => 'Arabic',
'EG' => 'Arabic',
'ZA' => 'English', // South Africa has 11 official languages
'NG' => 'English',
'AU' => 'English',
'AR' => 'Spanish',
'CH' => 'German', // Also French, Italian, Romansh
'BE' => 'Dutch', // Also French and German
'NL' => 'Dutch',
'SE' => 'Swedish',
'NO' => 'Norwegian',
'DK' => 'Danish',
'FI' => 'Finnish',
'PL' => 'Polish',
'TR' => 'Turkish',
'IR' => 'Persian',
'TH' => 'Thai',
'VN' => 'Vietnamese',
'ID' => 'Indonesian',
'PK' => 'Urdu',
'BD' => 'Bengali',
'UA' => 'Ukrainian',
'GR' => 'Greek',
'IL' => 'Hebrew',
'MY' => 'Malay',
'PH' => 'Filipino',
'KE' => 'English', // Also Swahili
];
return $countryLanguages[$countryCode] ?? null;
}
public static function fromCountryName(string $countryName): ?string
{
$countryLanguages = [
'United States' => 'English (US)',
'United Kingdom' => 'English',
'Canada' => 'English', // French is also official
'France' => 'French',
'Germany' => 'German',
'Italy' => 'Italian',
'Spain' => 'Spanish',
'Mexico' => 'Spanish',
'Brazil' => 'Portuguese',
'Portugal' => 'Portuguese',
'China' => 'Mandarin',
'Japan' => 'Japanese',
'South Korea' => 'Korean',
'Russia' => 'Russian',
'India' => 'Hindi', // Also English and many regional languages
'Saudi Arabia' => 'Arabic',
'Egypt' => 'Arabic',
'South Africa' => 'English', // One of 11 official languages
'Nigeria' => 'English',
'Australia' => 'English',
'Argentina' => 'Spanish',
'Switzerland' => 'German', // Also French, Italian, Romansh
'Belgium' => 'Dutch', // Also French and German
'Netherlands' => 'Dutch',
'Sweden' => 'Swedish',
'Norway' => 'Norwegian',
'Denmark' => 'Danish',
'Finland' => 'Finnish',
'Poland' => 'Polish',
'Turkey' => 'Turkish',
'Iran' => 'Persian',
'Thailand' => 'Thai',
'Vietnam' => 'Vietnamese',
'Indonesia' => 'Indonesian',
'Pakistan' => 'Urdu',
'Bangladesh' => 'Bengali',
'Ukraine' => 'Ukrainian',
'Greece' => 'Greek',
'Israel' => 'Hebrew',
'Malaysia' => 'Malay',
'Philippines' => 'Filipino',
'Kenya' => 'English', // Also Swahili
];
return $countryLanguages[$countryName] ?? null;
}
}

26
src/Util/ProviderList.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Util;
class ProviderList
{
public static $providers = [
'1337x',
'Comando',
'EZTV',
'ilCorSaRoNeRo',
'MagnetDL',
'MejorTorrent',
'RARBG',
'Rutor',
'Rutracker',
'ThePirateBay',
'Torrent9',
'TorrentGalaxy',
];
public static function getProviders()
{
return self::$providers;
}
}

View File

@@ -41,6 +41,18 @@
"config/packages/http_discovery.yaml"
]
},
"phpstan/phpstan": {
"version": "2.1",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
},
"symfony/asset-mapper": {
"version": "7.2",
"recipe": {
@@ -158,6 +170,18 @@
"config/routes.yaml"
]
},
"symfony/scheduler": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
},
"files": [
"src/Schedule.php"
]
},
"symfony/security-bundle": {
"version": "7.2",
"recipe": {
@@ -186,6 +210,19 @@
"assets/controllers/hello_controller.js"
]
},
"symfony/translation": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.3",
"ref": "e28e27f53663cc34f0be2837aba18e3a1bef8e7b"
},
"files": [
"config/packages/translation.yaml",
"translations/.gitignore"
]
},
"symfony/twig-bundle": {
"version": "7.2",
"recipe": {
@@ -256,6 +293,19 @@
"config/packages/validator.yaml"
]
},
"symfony/web-profiler-bundle": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.1",
"ref": "8b51135b84f4266e3b4c8a6dc23c9d1e32e543b7"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"symfonycasts/tailwind-bundle": {
"version": "0.10",
"recipe": {

21
templates/bare.html.twig Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body class="bg-cyan-950 flex flex-col">
<h1 class="px-4 py-4 text-3xl font-extrabold text-orange-500">Torsearch</h1>
<div class="flex flex-col justify-center items-center">
{% block body %}{% endblock %}
</div>
</body>
</html>

View File

@@ -20,6 +20,7 @@
<twig:NavBar />
</div>
<div class="col-span-5">
<h2 class="p-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %}
</div>
</div>

View File

@@ -15,11 +15,7 @@
</template>
</turbo-stream>
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
</template>
</turbo-stream>
<twig:Alert title="Success" message="{{ entity.title }} has been added to the Download queue" alert_id="{{ entity.id }}" data-controller="alert" />
{% endblock %}
{% block update %}

View File

@@ -0,0 +1,94 @@
<div id="filter" class="flex flex-col gap-4"
{{ stimulus_controller('result_filter') }}
{{ stimulus_action('result_filter', 'filter', 'change') }}
data-result-filter-media-type-value="{{ results.media.mediaType }}"
data-result-filter-movie-results-outlet=".results"
data-result-filter-tv-results-outlet=".results"
>
<div class="w-full p-4 flex flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution">
Resolution
<select id="resolution"
data-result-filter-target="resolution"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
value="{{ app.user.userPreferenceValues["resolution"] }}"
>
<option value="">n/a</option>
{% for option in this.preferences['resolution'] %}
<option value="{{ option.value }}"
{{ option.value == this.userPreferences['resolution'] ? 'selected' }}
>{{ option.name }}</option>
{% endfor %}
</select>
</label>
<label for="codec">
Codec
<select id="codec" data-result-filter-target="codec" class="px-1 py-0.5 bg-stone-100 text-sm text-gray-800 rounded-md">
<option value="">n/a</option>
{% for option in this.preferences['codec'] %}
<option value="{{ option.value }}"
{{ option.value == this.userPreferences['codec'] ? 'selected' }}
>{{ option.name }}</option>
{% endfor %}
</select>
</label>
<label for="language">
Language
<select id="language"
data-result-filter-target="language"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{% if this.userPreferences['language'] != null %}
data-preferred="{{ this.userPreferences['language'] }}"
{% endif %}
>
</select>
</label>
<label for="provider">
Provider
<select id="provider"
data-result-filter-target="provider"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{% if this.userPreferences['provider'] != null %}
data-preferred="{{ this.userPreferences['provider'] }}"
{% endif %}
>
</select>
</label>
{% if results.media.mediaType == "tvshows" %}
<label for="season">
Season
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}>
<option selected value="1">1</option>
{% for season in range(2, results.media.episodes|length) %}
<option value="{{ season }}">{{ season }}</option>
{% endfor %}
</select>
</label>
{# <label for="episodeNumber">#}
{# Episode#}
{# <select id="episodeNumber" name="episodeNumber" data-result-filter-target="episode" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-sm">#}
{# <option selected value="">n/a</option>#}
{# </select>#}
{# </label>#}
{% endif %}
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
class="loading-icon"
>
<twig:ux:icon name="codex:loader" height="20" width="20" data-loading-icon-target="icon" class="text-end" />
</span>
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row gap-2 justify-end px-8">
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-md text-sm"
{{ stimulus_target('result_filter', 'downloadSelected') }}
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
>Download Selected</button>
<input type="checkbox" name="selectAll" id="selectAll"
{{ stimulus_target('result_filter', 'selectAll') }}
{{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }}
/>
</div>
{% endif %}
</div>

View File

@@ -6,13 +6,18 @@
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm">
<twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/>
<li><twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/></li>
<li>
<a href="{{ path('app_logout') }}">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div {{ turbo_stream_listen('alerts') }} class="absolute top-10 right-10 size-96">
<div {{ turbo_stream_listen('alerts') }} class="absolute top-10 right-10">
<div >
<ul id="alert_list">
</ul>

View File

@@ -1,24 +1,19 @@
<nav {{ attributes }} class="flex h-screen flex-col justify-between bg-cyan-950">
<nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950">
<div class="px-4 py-6">
<ul class="mt-6 space-y-1">
<li>
<a
href="{{ path('app_index') }}"
class="block rounded-lg
<a href="{{ path('app_index') }}"
class="block rounded-lg
bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60
px-4 py-2 text-sm font-medium text-gray-50"
>
px-4 py-2 text-sm font-medium text-gray-50">
Dashboard
</a>
</li>
<li>
<details class="group [&_summary::-webkit-details-marker]:hidden">
<summary
class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 text-gray-50 hover:bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60"
>
<span class="text-sm font-medium"> Downloads </span>
<summary class="flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 text-gray-50 hover:bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<span class="text-sm font-medium">Downloads</span>
<span class="shrink-0 transition duration-300 group-open:-rotate-180">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -34,22 +29,17 @@
</svg>
</span>
</summary>
<ul class="mt-2 space-y-1 px-4">
<li>
<a
href="#"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
<a href="{{ path('app_search') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
In Progress
</a>
</li>
<li>
<a
href="#"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
<a href="{{ path('app_search') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
Complete
</a>
</li>
@@ -58,29 +48,27 @@
</li>
<li>
<a
href="#"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
<a href="{{ path('app_media_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700">
Preferences
</a>
</li>
</ul>
</div>
<div class="sticky inset-x-0 bottom-0 border-t border-gray-100">
<a href="#" class="flex items-center gap-2 bg-white p-4 hover:bg-gray-50">
<img
alt=""
src="https://images.unsplash.com/photo-1600486913747-55e5470d6f40?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1770&q=80"
class="size-10 rounded-full object-cover"
/>
<div class="sticky inset-x-0 bottom-0 border-t border-orange-500">
<a href="#" class="nav-foot flex items-center gap-2 p-4 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<span class="rounded-full p-2 border-orange-500 border-2">
<twig:ux:icon name="ri:user-line" width="30" class="text-gray-50"/>
</span>
<div>
<p class="text-xs">
<strong class="block font-medium">Eric Frusciante</strong>
{% if app.user.name %}
<strong class="block font-medium text-white">{{ app.user.name }}</strong>
{% endif %}
<span> eric@frusciante.com </span>
<span class="text-white"> {{ app.user.email }} </span>
</p>
</div>
</a>

View File

@@ -11,8 +11,17 @@
placeholder="TV Show, Movie..."
/>
<button
class="absolute top-1 right-1 flex items-center rounded bg-green-600 py-1 px-2.5 border border-transparent text-center text-sm text-white transition-all shadow-sm hover:shadow focus:bg-green-700 focus:shadow-none active:bg-green-700 hover:bg-green-700 active:shadow-none disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none"
type="button"
class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center
text-sm text-white transition-all shadow-sm hover:shadow
focus:bg-green-700 focus:shadow-none active:bg-green-700
hover:bg-green-700 active:shadow-none disabled:pointer-events-none
disabled:opacity-50 disabled:shadow-none
text-white bg-green-800 bg-opacity-60 text-sm
hover:bg-green-900 border border-green-500
"
type="submit"
>
Search
</button>

View File

@@ -1,6 +1,13 @@
<div{{ attributes }}>
<div class="p-4 flex flex-row gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<img class="w-24" src="{{ poster }}" />
{% if poster != null and poster != "https://image.tmdb.org/t/p/w500" %}
<img class="w-24 rounded-lg" src="{{ poster }}" />
{% else %}
<div class="w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="16" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="w-full flex flex-col">
<h3 class="mb-4 text-xl font-medium leading-tight font-bold text-gray-50">
{{ title }} - {{ year }}

View File

@@ -8,7 +8,11 @@
<div class="w-full p-4 flex flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution">
Resolution
<select id="resolution" data-result-filter-target="resolution" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md">
<select id="resolution"
data-result-filter-target="resolution"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
value="{{ app.user.userPreferenceValues["resolution"] }}"
>
<option {{ filter.resolution == "n/a" ? "selected" }}
value="">n/a</option>
<option {{ filter.resolution == "720p" ? "selected" }}

View File

@@ -8,18 +8,69 @@
<div class="flex flex-row w-full gap-2">
<twig:Card title="" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-4 flex flex-row gap-6">
<img class="w-40" src="{{ results.media.poster }}" />
{% if results.media.poster != null %}
<img class="w-40 rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="w-full flex flex-col">
<h3 class="mb-4 text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} - {{ results.media.year }}
</h3>
<div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} - {{ results.media.year }}
</h3>
<div {{ stimulus_controller('monitor_button', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
title: results.media.title,
})}}
data-monitor-button-result-filter-outlet="#filter"
>
<button data-monitor-button-target="button" {{ stimulus_action('monitor_button', 'toggle', 'click') }}
class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm
px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2
border-green-500"
type="button"
>
Monitor
<svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<!-- Dropdown menu -->
<div data-monitor-button-target="options"
class="absolute mt-1 right-12 z-40 hidden divide-y rounded-md shadow-sm
w-44 bg-green-800 backdrop-filter bg-opacity-100 border-2 border-green-500"
>
<ul class="py-2 text-sm text-gray-100" aria-labelledby="dropdownDefaultButton">
<li {{ stimulus_action('monitor_button', 'monitorSeries', 'click') }}>
<button class="px-4 py-2 hover:bg-green-950 w-full text-left">
Entire Series
</button>
</li>
<li {{ stimulus_action('monitor_button', 'monitorSeason', 'click') }}>
<button class="px-4 py-2 hover:bg-green-950 w-full text-left">
Season
</button>
</li>
</ul>
</div>
</div>
</div>
<p class="text-gray-50">
{{ results.media.description }}
</p>
</div>
</div>
{{ include('search/partial/filter.html.twig') }}
<twig:Filter results="{{ results }}" filter="{{ filter }}" />
{% if "movies" == results.media.mediaType %}
<div class="results" {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}>

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