Compare commits

..

7 Commits

101 changed files with 609 additions and 1578 deletions

View File

@@ -1,16 +1,5 @@
{ {
"controllers": { "controllers": {
"@symfony/ux-autocomplete": {
"autocomplete": {
"enabled": true,
"fetch": "eager",
"autoimport": {
"tom-select/dist/css/tom-select.default.css": true,
"tom-select/dist/css/tom-select.bootstrap4.css": false,
"tom-select/dist/css/tom-select.bootstrap5.css": false
}
}
},
"@symfony/ux-live-component": { "@symfony/ux-live-component": {
"live": { "live": {
"enabled": true, "enabled": true,

View File

@@ -1,46 +0,0 @@
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 {
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)
}
default() {
console.log('Looks like you need to add an action to your action button...')
}
monitorDispatch() {
fetch('/api/monitor/dispatch')
}
}

View File

@@ -0,0 +1,30 @@
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 {
initialize() {}
connect() {}
disconnect() {}
async clearAll() {
let response = await fetch('/api/torrentio/cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
type: 'torrentio',
mediaType: 'tvshows',
})
});
response = await response.json()
}
}

View File

@@ -1,43 +0,0 @@
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 outlets = ['navbar']
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)
}
toggleMenu() {
this.navbarOutlet.toggle();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -8,7 +8,7 @@ import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller { export default class extends Controller {
static targets = ['button', 'options'] static targets = ['button', 'options']
static outlets = ['result-filter', 'dialog'] static outlets = ['result-filter']
static values = { static values = {
tmdbId: String, tmdbId: String,
imdbId: String, imdbId: String,
@@ -54,9 +54,6 @@ export default class extends Controller {
title: this.titleValue, title: this.titleValue,
monitorType: 'tvshows', monitorType: 'tvshows',
}); });
if (this.hasDialogOutlet) {
this.dialogOutlet.close();
}
} }
async monitorSeason() { async monitorSeason() {

View File

@@ -16,7 +16,6 @@ export default class extends Controller {
}; };
static targets = ['list'] static targets = ['list']
static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false optionsLoaded = false
@@ -34,8 +33,6 @@ export default class extends Controller {
this.element.innerHTML = response; this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr'); this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue); this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon();
}); });
} }
} }
@@ -55,12 +52,10 @@ export default class extends Controller {
"resolution": option.querySelector('#resolution').textContent.trim(), "resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(), "codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(), "provider": option.querySelector('#provider').textContent.trim(),
"quality": option.dataset['quality'],
"languages": JSON.parse(option.dataset['languages']), "languages": JSON.parse(option.dataset['languages']),
} }
let include = true; let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden'); option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false; option.querySelector('input[type="checkbox"]').checked = false;
@@ -86,7 +81,6 @@ export default class extends Controller {
} }
if (false === include) { if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden'); option.classList.add('hidden');
} else if (true === firstIncluded) { } else if (true === firstIncluded) {
count = 1; count = 1;

View File

@@ -10,21 +10,16 @@ export default class extends Controller {
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"; 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() { connect() {
this.element.querySelectorAll('.nav-list a:not(.nav-foot)').forEach(link => { console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles; link.className = this.inactiveStyles;
if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) { if (window.location.pathname === (new URL(link.href)).pathname || window.location.pathname.startsWith( (new URL(link.href)).pathname + '/' ) ) {
link.className = this.activeStyles; link.className = this.activeStyles;
} }
}); });
window.addEventListener("resize", (event) => {
});
} }
toggle() { setActive() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
} }
} }

View File

@@ -11,7 +11,6 @@ export default class extends Controller {
languages = [] languages = []
providers = [] providers = []
qualities = []
seasons = [] seasons = []
activeFilter = { activeFilter = {
@@ -19,16 +18,13 @@ export default class extends Controller {
"codec": "", "codec": "",
"language": "", "language": "",
"provider": "", "provider": "",
"quality": "",
} }
static outlets = ['movie-results', 'tv-results', 'tv-episode-list'] static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
static values = { static values = {
'imdbId': String,
'media-type': String, 'media-type': String,
'episodes': Array, 'episodes': Array,
'reverseMappedQualities': Object,
} }
async connect() { async connect() {
@@ -38,12 +34,21 @@ export default class extends Controller {
await this.filter(); await this.filter();
} }
// Event is fired from movies/tvshows controllers to populate this data async movieResultsOutletConnected(outlet) {
async loadOptions({detail: { options }}) { await this.parseDownloadOptionForFilter(outlet)
await options.forEach((option) => { }
async tvResultsOutletConnected(outlet) {
await this.parseDownloadOptionForFilter(outlet)
}
async parseDownloadOptionForFilter(outlet) {
if (outlet.options.length === 0) {
await outlet.setOptions();
}
outlet.options.forEach((option) => {
this.addLanguages(option, option.dataset); this.addLanguages(option, option.dataset);
this.addProviders(option, option.dataset); this.addProviders(option, option.dataset);
this.addQualities(option, option.dataset);
}) })
await this.filter(); await this.filter();
} }
@@ -100,32 +105,6 @@ export default class extends Controller {
} }
addQualities(option, props) {
if (!this.qualities.includes(props['quality'])) {
if (props['quality'].toLowerCase() in this.reverseMappedQualitiesValue) {
this.qualities.push(props['quality']);
}
}
const preferred = this.qualityTarget.dataset.preferred;
if (preferred) {
this.qualityTarget.innerHTML = '<option value="'+preferred+'" selected>'+preferred+'</option>';
this.qualityTarget.innerHTML += '<option value="">n/a</option>';
} else {
this.qualityTarget.innerHTML = '<option value="">n/a</option>';
}
this.qualityTarget.innerHTML += this.qualities.sort()
.map((quality) => {
const preferred = this.qualityTarget.dataset.preferred;
if (preferred === quality) {
return;
}
return '<option value="' + quality + '">' + quality + '</option>'
})
.join();
}
async filter() { async filter() {
const currentSeason = this.activeFilter['season']; const currentSeason = this.activeFilter['season'];
@@ -135,7 +114,6 @@ export default class extends Controller {
"codec": this.codecTarget.value, "codec": this.codecTarget.value,
"language": this.languageTarget.value, "language": this.languageTarget.value,
"provider": this.providerTarget.value, "provider": this.providerTarget.value,
"quality": this.qualityTarget.value,
} }
if ("movies" === this.mediaTypeValue) { if ("movies" === this.mediaTypeValue) {
@@ -157,14 +135,6 @@ export default class extends Controller {
this.selectAllTarget.checked = false; this.selectAllTarget.checked = false;
} }
downloadSeason() {
fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, {
headers: {
'Content-Type': 'application/json'
}
})
}
selectAllEpisodes() { selectAllEpisodes() {
this.tvResultsOutlets.forEach((episode) => { this.tvResultsOutlets.forEach((episode) => {
if (episode.isActive()) { if (episode.isActive()) {

View File

@@ -1,59 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
initialize() {
this._onPreConnect = this._onPreConnect.bind(this);
this._onConnect = this._onConnect.bind(this);
}
connect() {
document.querySelector("#search").onsubmit = (event) => {
event.preventDefault();
const autocompleteController = this.application.getControllerForElementAndIdentifier(this.element, 'symfony--ux-autocomplete--autocomplete')
window.location.href = `/search?term=${autocompleteController.tomSelect.lastValue}`
}
this.element.addEventListener('autocomplete:pre-connect', this._onPreConnect);
this.element.addEventListener('autocomplete:connect', this._onConnect);
}
disconnect() {
// You should always remove listeners when the controller is disconnected to avoid side-effects
this.element.removeEventListener('autocomplete:connect', this._onConnect);
this.element.removeEventListener('autocomplete:pre-connect', this._onPreConnect);
}
_onPreConnect(event) {
// TomSelect has not been initialized - options can be changed
// console.log(event.detail); // Options that will be used to initialize TomSelect
event.detail.options.onItemAdd = (value, $item) => {
const params = value.split('|')
window.location.href = `/result/${params[0]}/${params[1]}`
};
event.detail.options.render.loading = (data, escape) => {
return `
<span data-controller="loading-icon" data-loading-icon-total-value="52" data-loading-icon-count-value="20" class="loading-icon">
<svg viewBox="0 0 24 24" fill="currentColor" height="20" width="20" data-loading-icon-target="icon" class="text-end" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2" d="M12 6.99998C9.1747 6.99987 6.99997 9.24998 7 12C7.00003 14.55 9.02119 17 12 17C14.7712 17 17 14.75 17 12"><animateTransform attributeName="transform" attributeType="XML" dur="560ms" from="0,12,12" repeatCount="indefinite" to="360,12,12" type="rotate"></animateTransform></path></svg>
</span>
`;
}
event.detail.options.render.option = (data, escape) => {
if (data.data.description.length > 60) {
data.data.description = data.data.description.substring(0, 107) + "...";
}
return `<div class="flex flex-row">
<img src="${data.data.poster}" class="w-16 rounded-md">
<div class="p-2 flex flex-col">
<h2>${data.data.title}</h2>
<p class="max-w-[60ch] text-wrap">${data.data.description}</p>
</div>
</div>
`
}
}
_onConnect(event) {
// TomSelect has just been initialized and you can access details from the event
// console.log(event.detail.tomSelect); // TomSelect instance
// console.log(event.detail.options); // Options used to initialize TomSelect
}
}

View File

@@ -51,7 +51,6 @@ export default class extends Controller {
this.countTarget.innerText = 0; this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true; this.episodeSelectorTarget.disabled = true;
} }
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.increaseCount(); this.loadingIconOutlet.increaseCount();
} else { } else {
console.log(`HTTP Response Code: ${response?.status}`) console.log(`HTTP Response Code: ${response?.status}`)
@@ -91,7 +90,13 @@ export default class extends Controller {
} }
toggleList() { toggleList() {
this.listTarget.classList.toggle('options-table'); // if (!this.isOpen) {
// this.toggleButtonTarget.classList.add('rotate-180');
// this.toggleButtonTarget.classList.remove('-rotate-180');
// } else {
// this.toggleButtonTarget.classList.add('-rotate-180');
// this.toggleButtonTarget.classList.remove('rotate-180');
// }
this.listTarget.classList.toggle('hidden'); this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90'); this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90'); this.toggleButtonTarget.classList.toggle('-rotate-90');
@@ -136,7 +141,6 @@ export default class extends Controller {
} }
let include = true; let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden'); option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false; option.querySelector('input[type="checkbox"]').checked = false;
@@ -162,7 +166,6 @@ export default class extends Controller {
} }
if (false === include) { if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden'); option.classList.add('hidden');
} else if (true === firstIncluded) { } else if (true === firstIncluded) {
count = 1; count = 1;

View File

@@ -1 +0,0 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 0 0"><path fill="currentColor" fill-rule="evenodd" d="M0 3.75A.75.75 0 0 1 .75 3h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 3.75M0 8a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8m.75 3.5a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 333 B

View File

@@ -23,7 +23,14 @@
@apply bg-green-950 hover:bg-green-900 border-green-500 @apply bg-green-950 hover:bg-green-900 border-green-500
} }
.alert-warning { .alert-warning {
@apply bg-yellow-500 hover:bg-yellow-600 border-yellow-400 text-black @apply bg-yellow-500/70 hover:bg-yellow-600 border-yellow-400 text-black
}
.primary-btn {
@apply px-1 py-1 rounded-md bg-orange-500 self-end text-white w-16 ml-2 hover:bg-orange-600
}
.secondary-btn {
@apply px-1 py-1 rounded-md self-end w-16 hover:bg-stone-100
} }
} }
@@ -63,62 +70,3 @@ dialog[data-dialog-target="dialog"][open] {
dialog[data-dialog-target="dialog"][closing] { dialog[data-dialog-target="dialog"][closing] {
animation: fade-out 200ms forwards; animation: fade-out 200ms forwards;
} }
.r-tablecell {
display: none;
}
.r-tablerow {
display: flex;
}
@media screen and (min-width: 768px) {
.r-tablecell {
display: inline-table;
}
.r-tablerow {
display: table-row;
}
}
.options-table {
display: flex;
:last-child {
border-bottom: none;
}
}
@media screen and (min-width: 768px) {
.options-table {
display: inline-table;
}
}
#search .ts-wrapper.single .ts-control::after {
display: none !important;
}
#search .ts-control {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
padding-left: 0;
input {
color: #fff !important;
padding: 0;
}
}
#search .ts-dropdown {
background: unset;
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
#search .ts-dropdown .ts-dropdown-content .option.active {
background: unset;
@apply bg-orange-500/80 text-black font-bold rounded-md
}

View File

@@ -40,7 +40,7 @@ services:
tty: true tty: true
environment: environment:
TZ: America/Chicago TZ: America/Chicago
command: php /app/bin/console messenger:consume async -vv --time-limit=3600 command: php /app/bin/console messenger:consume media_cache -vv --time-limit=3600
scheduler: scheduler:

View File

@@ -44,7 +44,6 @@
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.3.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27",
"symfony/ux-icons": "^2.24", "symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24", "symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24", "symfony/ux-turbo": "^2.24",

92
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "248d1e534ec6bb56594a7380fb2eb860", "content-hash": "67e697578f7237f60726c0d93bfed001",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -9071,96 +9071,6 @@
], ],
"time": "2025-03-30T12:17:06+00:00" "time": "2025-03-30T12:17:06+00:00"
}, },
{
"name": "symfony/ux-autocomplete",
"version": "v2.27.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/ux-autocomplete.git",
"reference": "ab0be7ef7d59ea6925fd6fabccbd4d04cb5f5e06"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/ux-autocomplete/zipball/ab0be7ef7d59ea6925fd6fabccbd4d04cb5f5e06",
"reference": "ab0be7ef7d59ea6925fd6fabccbd4d04cb5f5e06",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/dependency-injection": "^6.3|^7.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/http-foundation": "^6.3|^7.0",
"symfony/http-kernel": "^6.3|^7.0",
"symfony/property-access": "^6.3|^7.0"
},
"conflict": {
"doctrine/orm": "2.9.0 || 2.9.1"
},
"require-dev": {
"doctrine/collections": "^1.6.8|^2.0",
"doctrine/doctrine-bundle": "^2.4.3",
"doctrine/orm": "^2.9.4|^3.0",
"fakerphp/faker": "^1.22",
"mtdowling/jmespath.php": "^2.6",
"symfony/form": "^6.3|^7.0",
"symfony/framework-bundle": "^6.3|^7.0",
"symfony/maker-bundle": "^1.40",
"symfony/options-resolver": "^6.3|^7.0",
"symfony/phpunit-bridge": "^6.3|^7.0",
"symfony/process": "^6.3|^7.0",
"symfony/security-bundle": "^6.3|^7.0",
"symfony/twig-bundle": "^6.3|^7.0",
"symfony/uid": "^6.3|^7.0",
"twig/twig": "^2.14.7|^3.0.4",
"zenstruck/browser": "^1.1",
"zenstruck/foundry": "1.37.*"
},
"type": "symfony-bundle",
"extra": {
"thanks": {
"url": "https://github.com/symfony/ux",
"name": "symfony/ux"
}
},
"autoload": {
"psr-4": {
"Symfony\\UX\\Autocomplete\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "JavaScript Autocomplete functionality for Symfony",
"homepage": "https://symfony.com",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/ux-autocomplete/tree/v2.27.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-06-21T10:08:18+00:00"
},
{ {
"name": "symfony/ux-icons", "name": "symfony/ux-icons",
"version": "v2.26.0", "version": "v2.26.0",

View File

@@ -19,5 +19,4 @@ return [
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true], Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle::class => ['all' => true],
Symfony\UX\Autocomplete\AutocompleteBundle::class => ['all' => true],
]; ];

View File

@@ -15,5 +15,11 @@ framework:
#app: cache.adapter.apcu #app: cache.adapter.apcu
# Namespaced pools use the above "app" backend by default # Namespaced pools use the above "app" backend by default
#pools: pools:
#my.dedicated.cache: null torrentio.cache:
adapter: cache.app
tmdb.cache:
adapter: cache.app
default_lifetime: 2592000
page.cache:
adapter: cache.app

View File

@@ -37,7 +37,6 @@ framework:
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async 'App\Download\Action\Command\DownloadMediaCommand': async
'App\Download\Action\Command\DownloadSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async 'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async
'App\Monitor\Action\Command\MonitorTvSeasonCommand': async 'App\Monitor\Action\Command\MonitorTvSeasonCommand': async
'App\Monitor\Action\Command\MonitorTvShowCommand': async 'App\Monitor\Action\Command\MonitorTvShowCommand': async

View File

@@ -1,15 +1,7 @@
controllersBase: controllersIndex:
resource: resource:
path: ../src/Base/Framework/Controller/ path: ../src/Controller/
namespace: App\Base\Framework\Controller namespace: App\Controller
type: attribute
defaults:
schemes: [ 'https' ]
controllersSearch:
resource:
path: ../src/Search/Framework/Controller/
namespace: App\Search\Framework\Controller
type: attribute type: attribute
defaults: defaults:
schemes: [ 'https' ] schemes: [ 'https' ]
@@ -44,12 +36,5 @@ controllersTorrentio:
namespace: App\Torrentio\Framework\Controller namespace: App\Torrentio\Framework\Controller
type: attribute type: attribute
defaults: defaults:
schemes: ['https'] schemes: [ 'https' ]
controllersTmdb:
resource:
path: ../src/Tmdb/Framework/Controller
namespace: App\Tmdb\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

@@ -1,3 +0,0 @@
ux_autocomplete:
resource: '@AutocompleteBundle/config/routes.php'
prefix: '/autocomplete'

View File

@@ -4,15 +4,6 @@
# Put parameters here that don't need to change on each machine where the app is deployed # 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 # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
# App
app.url: '%env(APP_URL)%'
# Debrid Services
app.debrid.real_debrid.key: '%env(REAL_DEBRID_KEY)%'
# TMDB Key
app.meta_provider.tmdb.key: '%env(TMDB_API)%'
# Media # Media
media.base_path: '/var/download' media.base_path: '/var/download'
media.default_movies_dir: movies media.default_movies_dir: movies

View File

@@ -3,7 +3,7 @@
# or pass your certificates into the 'app' container. # or pass your certificates into the 'app' container.
# Please omit any trailing slashes. The APP_URL is # Please omit any trailing slashes. The APP_URL is
# used to generate the Mercure URL behind the scenes. # used to generate the Mercure URL behind the scenes.
APP_URL="https://dev.caldwell.digital" APP_URL="https://torsearch.idocode.io"
APP_SECRET="70169beadfbc8101c393cbfbba27a313" APP_SECRET="70169beadfbc8101c393cbfbba27a313"
APP_ENV=prod APP_ENV=prod

View File

@@ -1,16 +1,4 @@
services: services:
caddy:
image: caddy:2.9.1
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- $PWD/../../bash/caddy:/etc/caddy
- $PWD/../../bash/certs:/etc/ssl
# The "entrypoint" into the application. This reverse proxy # The "entrypoint" into the application. This reverse proxy
# proxies traffic back to their respective services. If not # proxies traffic back to their respective services. If not
# running behind a reverse proxy inject your SSL certificates # running behind a reverse proxy inject your SSL certificates
@@ -24,8 +12,8 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- ./downloads/movies:/var/download/movies - /mnt/media/downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows - /mnt/media/downloads/tvshows:/var/download/tvshows
environment: environment:
TZ: America/Chicago TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
@@ -44,8 +32,8 @@ services:
worker: worker:
image: code.caldwell.digital/home/torsearch-worker:latest image: code.caldwell.digital/home/torsearch-worker:latest
volumes: volumes:
- ./downloads/movies:/var/download/movies - /mnt/media/downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows - /mnt/media/downloads/tvshows:/var/download/tvshows
environment: environment:
TZ: America/Chicago TZ: America/Chicago
command: -vvv command: -vvv
@@ -64,8 +52,8 @@ services:
scheduler: scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes: volumes:
- ./downloads/movies:/var/download/movies - /mnt/media/downloads/movies:/var/download/movies
- ./downloads/tvshows:/var/download/tvshows - /mnt/media/downloads/tvshows:/var/download/tvshows
env_file: env_file:
- .env - .env
environment: environment:

View File

@@ -40,28 +40,4 @@ return [
'stimulus-use' => [ 'stimulus-use' => [
'version' => '0.52.2', 'version' => '0.52.2',
], ],
'animate.css' => [
'version' => '4.1.1',
],
'animate.css/animate.min.css' => [
'version' => '4.1.1',
'type' => 'css',
],
'tom-select' => [
'version' => '2.4.3',
],
'@orchidjs/sifter' => [
'version' => '1.1.0',
],
'@orchidjs/unicode-variants' => [
'version' => '1.1.2',
],
'tom-select/dist/css/tom-select.default.min.css' => [
'version' => '2.4.3',
'type' => 'css',
],
'tom-select/dist/css/tom-select.default.css' => [
'version' => '2.4.3',
'type' => 'css',
],
]; ];

View File

@@ -1,35 +0,0 @@
<?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 Version20250708033046 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor ADD only_future TINYINT(1) NOT NULL DEFAULT 1
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE monitor DROP only_future
SQL);
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Base;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class ConfigResolver
{
private array $messages = [];
public function __construct(
#[Autowire(param: 'app.url')]
private readonly ?string $appUrl = null,
#[Autowire(param: 'app.debrid.real_debrid.key')]
private readonly ?string $realDebridApiKey = null,
#[Autowire(param: 'app.meta_provider.tmdb.key')]
private readonly ?string $tmdbApiKey = null,
#[Autowire(param: 'media.movies_path')]
private readonly ?string $moviesPath = null,
#[Autowire(param: 'media.tvshows.path')]
private readonly ?string $tvshowsPath = null,
) {}
public function validate(): bool
{
$valid = true;
if (null === $this->realDebridApiKey || "" === $this->realDebridApiKey) {
$this->messages[] = "Your Real Debrid API key is missing. Please set it to the 'REAL_DEBRID_KEY' environment variable.";
$valid = false;
}
if (null === $this->tmdbApiKey || "" === $this->tmdbApiKey) {
$this->messages[] = "Your TMDB API key is missing. Please set it to the 'TMDB_API' environment variable.";
$valid = false;
}
return $valid;
}
public function getMessages(): array
{
return $this->messages;
}
}

View File

@@ -1,115 +0,0 @@
<?php
namespace App\Base\Util;
class QualityList
{
public static $qualities = [
"dvd-rip" => [
"dvdrip",
"dvdmux",
"dvdr",
"dvd-full",
"full-rip",
"iso rip",
"lossless rip",
"untouched rip",
"dvd-5",
"dvd-9",
],
"hdtv, pdtv or dsrip" => [
"dsr",
"dsrip",
"satrip",
"dthrip",
"dvbrip",
"hdtv",
"pdtv",
"dtvrip",
"tvrip",
"hdtvrip",
],
"vodrip" => [
"vodrip",
"vodr",
],
"hc hd-rip" => [
"hc",
"hd-rip",
],
"webcap" => [
"web-cap",
"webcap",
"web cap",
],
"hdrip" => [
"hdrip",
"web-dlrip",
],
"webrip" => [
"webrip",
"web rip",
"web-rip",
"webrip (p2p)",
"web rip (p2p)",
"web-rip (p2p)",
],
"web-dl" => [
"webdl",
"web dl",
"web-dl",
"web (scene)",
"webrip",
],
"blu-ray/bd/brrip" => [
"blu-ray",
"bluray",
"bluray",
"bdrip",
"brip",
"brrip",
"bdr[13]",
"bd25",
"bd50",
"bd66",
"bd100",
"bd5",
"bd9",
"bdmv",
"bdiso",
"complete.bluray",
],
"4k" => [
"cbr",
"vbr",
],
];
public static function getQualities(): array
{
return self::$qualities;
}
public static function getBaseQualities(): array
{
return array_keys(self::$qualities);
}
public static function getBaseQualityFromSubQuality(string $key): ?string
{
return array_search($key, self::$qualities) ?? null;
}
public static function getAsReverseMap(): array
{
$results = [];
foreach (self::$qualities as $baseQualtiy => $subQualities) {
foreach ($subQualities as $subQuality) {
$results[$subQuality] = $baseQualtiy;
}
}
return $results;
}
}

View File

@@ -1,11 +1,12 @@
<?php <?php
namespace App\Base\Framework\Command; namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;

View File

@@ -1,12 +1,9 @@
<?php <?php
namespace App\Base\Framework\Command; namespace App\Command;
use App\User\Framework\Entity\Preference;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferenceOptionRepository; use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
@@ -21,17 +18,14 @@ class SeedDatabaseCommand extends Command
{ {
private PreferencesRepository $preferenceRepository; private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository; private PreferenceOptionRepository $preferenceOptionRepository;
private UserRepository $userRepository;
public function __construct( public function __construct(
PreferencesRepository $preferenceRepository, PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository, PreferenceOptionRepository $preferenceOptionRepository,
UserRepository $userRepository,
) { ) {
parent::__construct(); parent::__construct();
$this->preferenceRepository = $preferenceRepository; $this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository; $this->preferenceOptionRepository = $preferenceOptionRepository;
$this->userRepository = $userRepository;
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
@@ -40,7 +34,6 @@ class SeedDatabaseCommand extends Command
$this->seedPreferences($io); $this->seedPreferences($io);
$this->seedPreferenceOptions($io); $this->seedPreferenceOptions($io);
$this->updateUserPreferences($io);
return Command::SUCCESS; return Command::SUCCESS;
} }
@@ -51,48 +44,22 @@ class SeedDatabaseCommand extends Command
$preferences = $this->getPreferences(); $preferences = $this->getPreferences();
foreach ($preferences as $preference) { foreach ($preferences as $preference) {
$isNewRecord = false; if ($this->preferenceRepository->find($preference['id'])) {
$preferenceRecord = $this->preferenceRepository->findOneBy(['id' => $preference['id']]); continue;
if (null === $preferenceRecord) {
$isNewRecord = true;
$preferenceRecord = new Preference();
} }
$preferenceRecord $this->preferenceRepository->getEntityManager()->persist((new \App\User\Framework\Entity\Preference())
->setId($preference['id']) ->setId($preference['id'])
->setName($preference['name']) ->setName($preference['name'])
->setDescription($preference['description']) ->setDescription($preference['description'])
->setEnabled($preference['enabled']) ->setEnabled($preference['enabled'])
->setType($preference['type']); ->setType($preference['type'])
);
if (true === $isNewRecord) {
$this->preferenceRepository->getEntityManager()->persist($preferenceRecord);
}
} }
$this->preferenceRepository->getEntityManager()->flush(); $this->preferenceRepository->getEntityManager()->flush();
} }
private function updateUserPreferences(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Updating user preferences...');
$users = $this->userRepository->findAll();
$preferences = $this->preferenceRepository->findAll();
foreach ($users as $user) {
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference(
new UserPreference()
->setPreference($preference)
->setUser($user)
->setPreferenceValue(null)
);
}
}
}
$this->userRepository->getEntityManager()->flush();
}
private function getPreferences(): array private function getPreferences(): array
{ {
return [ return [
@@ -124,13 +91,6 @@ class SeedDatabaseCommand extends Command
'enabled' => true, 'enabled' => true,
'type' => 'media', 'type' => 'media',
], ],
[
'id' => 'quality',
'name' => 'Quality',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[ [
'id' => 'movie_folder', 'id' => 'movie_folder',
'name' => 'Create new folder for Movies', 'name' => 'Create new folder for Movies',

View File

@@ -1,11 +1,13 @@
<?php <?php
namespace App\Base\Framework\Command; namespace App\Command;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;

View File

@@ -1,8 +1,8 @@
<?php <?php
namespace App\Base\Framework\Controller; namespace App\Controller;
use App\Base\Util\Broadcaster; use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;

View File

@@ -1,10 +1,11 @@
<?php <?php
namespace App\Base\Framework\Controller; namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\User\Framework\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -20,8 +21,6 @@ final class IndexController extends AbstractController
#[Route('/', name: 'app_index')] #[Route('/', name: 'app_index')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
/** @var User $user */
$user = $this->getUser();
return $this->render('index/index.html.twig', [ return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(), 'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(), 'recent_downloads' => $this->getUser()->getDownloads(),
@@ -29,4 +28,11 @@ final class IndexController extends AbstractController
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6), 'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]); ]);
} }
#[Route('/test', name: 'app_test')]
public function test()
{
$result = $this->monitorTvShowHandler->handle(new MonitorTvShowCommand(355));
return $this->json($result);
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Search\Framework\Controller; namespace App\Controller;
use App\Search\Action\Handler\GetMediaInfoHandler; use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Handler\SearchHandler; use App\Search\Action\Handler\SearchHandler;
@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class WebController extends AbstractController final class SearchController extends AbstractController
{ {
public function __construct( public function __construct(
private SearchHandler $searchHandler, private SearchHandler $searchHandler,

View File

@@ -1,13 +1,13 @@
<?php <?php
namespace App\Torrentio\Framework\Controller; namespace App\Controller;
use App\Base\Util\Broadcaster;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler; use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput; use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput; use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException; use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster;
use Carbon\Carbon; use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -15,8 +15,9 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class WebController extends AbstractController final class TorrentioController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler, private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
@@ -25,7 +26,7 @@ final class WebController extends AbstractController
) {} ) {}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')] #[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response public function movieOptions(GetMovieOptionsInput $input, TagAwareCacheInterface $pageCache): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.movies.%s.%s", "page.torrentio.movies.%s.%s",
@@ -33,17 +34,29 @@ final class WebController extends AbstractController
$input->imdbId $input->imdbId
); );
return $cache->get($cacheId, function (ItemInterface $item) use ($input) { try {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); return $pageCache->get($cacheId, function (ItemInterface $item) use ($input) {
$results = $this->getMovieOptionsHandler->handle($input->toCommand()); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
return $this->render('torrentio/movies.html.twig', [ $item->tag(['page', 'page.torrentio', 'page.torrentio.movies', "page.torrentio.movies.$input->tmdbId.$input->imdbId", 'torrentio', 'torrentio.movies', "torrentio.movies.$input->tmdbId.$input->imdbId"]);
'results' => $results, $results = $this->getMovieOptionsHandler->handle($input->toCommand());
]); return $this->render('torrentio/movies.html.twig', [
}); 'results' => $results,
]);
});
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
} }
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')] #[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response public function tvShowOptions(GetTvShowOptionsInput $input, TagAwareCacheInterface $pageCache): Response
{ {
$cacheId = sprintf( $cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s", "page.torrentio.tvshows.%s.%s.%s.%s",
@@ -54,13 +67,14 @@ final class WebController extends AbstractController
); );
try { try {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) { return $pageCache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['page', 'page.torrentio', 'page.torrentio.tvshows', "page.torrentio.tvshows.$input->tmdbId.$input->imdbId", "page.torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season", "page.torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season.$input->episode", 'torrentio', 'torrentio.tvshows', "torrentio.tvshows.$input->tmdbId.$input->imdbId", "torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season", "torrentio.tvshows.$input->tmdbId.$input->imdbId.$input->season.$input->episode", $input->imdbId, $input->tmdbId]);
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); $results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [ return $this->render('torrentio/tvshows.html.twig', [
'results' => $results, 'results' => $results,
]); ]);
// }); });
} catch (TorrentioRateLimitException $exception) { } catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning'); $this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig', return $this->render('bare.html.twig',

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DownloadSeasonCommand>
*/
class DownloadSeasonCommand implements CommandInterface
{
public function __construct(
public int $userId,
public int $season,
public string $imdbId,
public string $mediaType = 'tvshows',
) {}
}

View File

@@ -1,111 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Handler\MonitorTvEpisodeHandler;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Repository\UserRepository;
use Nihilarr\PTN;
use 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<DownloadSeasonCommand, DownloadMediaResult> */
readonly class DownloadSeasonHandler implements HandlerInterface
{
public function __construct(
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private Tmdb $tmdb,
private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private UserRepository $userRepository,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$series = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
$this->logger->info('> [DownloadTvSeasonHandler] Executing DownloadTvSeasonHandler for "' . $series->title . '" season ' . $command->season);
$episodesInSeason = Map::from($series->episodes[$command->season]);
$this->logger->info('> [DownloadTvSeasonHandler] ...Found ' . count($episodesInSeason) . ' episodes in season ' . $command->season);
$downloadCommands = [];
foreach ($episodesInSeason as $episode) {
$this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']);
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$series->tmdbId,
$command->imdbId,
$command->season,
$episode['episode_number']
)
);
$this->logger->info('> [DownloadTvSeasonHandler] ......Found ' . count($results->results) . ' total download options, beginning evaluation');
$userPreferences = UserPreferencesFactory::createFromUser(
$this->userRepository->findOneBy(['id' => $command->userId])
);
$result = $this->downloadOptionEvaluator->evaluateOptions($results->results, $userPreferences);
if (null !== $result) {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result');
$this->logger->info('> [DownloadTvSeasonHandler] ......Dispatching DownloadMediaCommand for "' . $series->title . '" season ' . $command->season . ' episode ' . $episode['episode_number']);
$downloadCommand = new DownloadMediaCommand(
$result->url,
$series->title,
$result->filename,
'tvshows',
$command->imdbId,
$command->userId,
);
$this->bus->dispatch($downloadCommand);
$downloadCommands[] = $downloadCommand;
} else {
$this->logger->info('> [DownloadTvSeasonHandler] ......Found 0 matching results');
}
}
return new DownloadSeasonResult(
status: 200,
message: 'Success',
data: ['downloads' => $downloadCommands],
);
}
private function getDownloadedEpisodes(string $title)
{
// Check current episodes
$downloadedEpisodes = $this->mediaFiles
->getEpisodes($title)
->map(fn($episode) => (object) (new PTN())->parse($episode))
->filter(fn ($episode) =>
property_exists($episode, 'episode')
&& property_exists($episode, 'season')
&& null !== $episode->episode
&& null !== $episode->season
)
->rekey(fn($episode) => $episode->episode);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle());
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DownloadSeasonInput> */
class DownloadSeasonInput implements InputInterface
{
public function __construct(
#[SourceRoute('imdbId')]
public string $imdbId,
#[SourceRoute('season')]
public int $season,
#[SourceRequest('mediaType')]
public string $mediaType = 'tvshows',
public ?int $userId = null,
) {}
public function toCommand(): CommandInterface
{
return new DownloadSeasonCommand(
$this->userId,
$this->season,
$this->imdbId,
$this->mediaType,
);
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<DownloadSeasonResult> */
class DownloadSeasonResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
public array $data,
) {}
}

View File

@@ -1,94 +0,0 @@
<?php
namespace App\Download;
use Aimeos\Map;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Result\TorrentioResult;
use App\User\Dto\UserPreferences;
class DownloadOptionEvaluator
{
/**
* @param Monitor $monitor
* @param TorrentioResult[] $results
* @return TorrentioResult|null
* @throws \Throwable
*/
public function evaluateOptions(array $results, UserPreferences $userPreferences): ?TorrentioResult
{
$sizeLow = 000;
$sizeHigh = 4096;
$bestMatches = [];
$matches = [];
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;
}
}

View File

@@ -2,18 +2,15 @@
namespace App\Download\Framework\Controller; namespace App\Download\Framework\Controller;
use App\Base\Util\Broadcaster;
use App\Download\Action\Handler\DeleteDownloadHandler; use App\Download\Action\Handler\DeleteDownloadHandler;
use App\Download\Action\Handler\DownloadSeasonHandler;
use App\Download\Action\Handler\PauseDownloadHandler; use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler; use App\Download\Action\Handler\ResumeDownloadHandler;
use App\Download\Action\Input\DeleteDownloadInput; use App\Download\Action\Input\DeleteDownloadInput;
use App\Download\Action\Input\DownloadMediaInput; use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Action\Input\DownloadSeasonInput;
use App\Download\Action\Input\PauseDownloadInput; use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput; use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\User\Dto\UserPreferencesFactory; use App\Util\Broadcaster;
use Nihilarr\PTN; use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -108,18 +105,4 @@ class ApiController extends AbstractController
return $this->json(['status' => 200, 'message' => 'Download Resumed']); return $this->json(['status' => 200, 'message' => 'Download Resumed']);
} }
#[Route('/api/download/season/{imdbId}/{season}', name: 'api_download_season', methods: ['GET'])]
public function downloadSeason(
DownloadSeasonInput $input,
): Response {
$input->userId = $this->getUser()->getId();
$this->bus->dispatch($input->toCommand());
$this->broadcaster->alert(
title: 'Success',
message: "Your download for season $input->season has been added to the queue.",
);
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
}
} }

View File

@@ -2,9 +2,9 @@
namespace App\Download\Framework\Repository; namespace App\Download\Framework\Repository;
use App\Base\Util\Paginator;
use App\Download\Framework\Entity\Download; use App\Download\Framework\Entity\Download;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Enum; namespace App\Enum;
enum MediaType: string enum MediaType: string
{ {

View File

@@ -3,14 +3,14 @@
namespace App\Monitor\Action\Handler; namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\DownloadOptionEvaluator;
use App\Monitor\Action\Command\MonitorMovieCommand; use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorMovieResult;
use App\Monitor\Action\Result\MonitorTvEpisodeResult; use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MonitorOptionEvaluator;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler; use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
use Carbon\Carbon; use Carbon\Carbon;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -25,7 +25,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private GetTvShowOptionsHandler $getTvShowOptionsHandler, private GetTvShowOptionsHandler $getTvShowOptionsHandler,
private DownloadOptionEvaluator $downloadOptionEvaluator, private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private LoggerInterface $logger, private LoggerInterface $logger,
@@ -35,63 +35,58 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
try { $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler');
$monitor = $this->monitorRepository->find($command->movieMonitorId); $monitor = $this->monitorRepository->find($command->movieMonitorId);
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler for ' . $monitor->getTitle() . ' season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode());
$episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode()); $episodeData = $this->tmdb->episodeDetails($monitor->getTmdbId(), $monitor->getSeason(), $monitor->getEpisode());
if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) { if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::now()) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Episode has not aired yet, skipping for now'); $this->logger->info('> [MonitorTvEpisodeHandler] Episode has not aired yet, skipping for now');
return new MonitorTvEpisodeResult( return new MonitorTvEpisodeResult(
status: 'OK', status: 'OK',
result: [ result: [
'message' => 'No change', 'message' => 'No change',
'monitor' => $monitor, 'monitor' => $monitor,
] ]
);
}
$monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$monitor->getTmdbId(),
$monitor->getImdbId(),
$monitor->getSeason(),
$monitor->getEpisode()
)
); );
}
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found ' . count($results->results) . ' total download options, beginning evaluation'); $monitor->setStatus('In Progress');
$this->monitorRepository->getEntityManager()->flush();
$result = $this->downloadOptionEvaluator->evaluateOptions($results->results, UserPreferencesFactory::createFromUser($monitor->getUser())); $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()
)
);
if (null !== $result) { $this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options');
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand( $result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
$result->url,
$monitor->getTitle(), if (null !== $result) {
$result->filename, $this->logger->info('> [MonitorTvEpisodeHandler] 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
'tvshows', $this->bus->dispatch(new DownloadMediaCommand(
$monitor->getImdbId(), $result->url,
$monitor->getUser()->getId(), $monitor->getTitle(),
)); $result->filename,
$monitor->setStatus('Complete'); 'tvshows',
$monitor->setDownloadedAt(new DateTimeImmutable()); $monitor->getImdbId(),
} else { $monitor->getUser()->getId(),
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 0 matching results found, monitor will run at next interval'); ));
$monitor->setStatus('Active'); $monitor->setStatus('Complete');
} $monitor->setDownloadedAt(new DateTimeImmutable());
} catch (\Throwable $exception) { } else {
$this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage()); $this->logger->info('> [MonitorTvEpisodeHandler] 0 matching results found, monitor will run at next interval');
$this->logger->error($exception->getMessage());
$monitor->setStatus('Active'); $monitor->setStatus('Active');
} }
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->monitorRepository->getEntityManager()->flush(); $this->entityManager->flush();
return new MonitorTvEpisodeResult( return new MonitorTvEpisodeResult(
status: 'OK', status: 'OK',

View File

@@ -61,27 +61,26 @@ readonly class MonitorTvShowHandler implements HandlerInterface
// Dispatch Episode commands for each missing Episode // Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) { foreach ($episodesInShow as $episode) {
// Only monitor future episodes // Only monitor future episodes
$this->logger->info('> [MonitorTvShowHandler] Evaluating "' . $monitor->getTitle() . '", season "' . $episode['season_number'] . '" episode "' . $episode['episode_number'] . '"');
$episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode); $episodeInFuture = $this->episodeReleasedAfterMonitorCreated($monitor->getCreatedAt(), $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) { if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping'); $this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping');
continue; continue;
} }
// Check if the episode is already downloaded // Check if the episode is already downloaded
$episodeExists = $this->episodeExists($episode, $downloadedEpisodes); $episodeExists = $this->episodeExists($episode, $downloadedEpisodes);
$this->logger->info('> [MonitorTvShowHandler] ...Episode exists? ' . (true === $episodeExists ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) { if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping'); $this->logger->info('> [MonitorTvShowHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping');
continue; continue;
} }
// Check for existing monitors // Check for existing monitors
$monitorExists = $this->monitorExists($monitor, $episode); $monitorExists = $this->monitorExists($monitor, $episode);
$this->logger->info('> [MonitorTvShowHandler] ...Monitor exists? ' . (true === $monitorExists ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] Monitor exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) { if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] ...Skipping'); $this->logger->info('> [MonitorTvShowHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping');
continue; continue;
} }
@@ -107,7 +106,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
// Immediately run the monitor // Immediately run the monitor
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId()); $command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command); $this->monitorTvEpisodeHandler->handle($command);
$this->logger->info('> [MonitorTvShowHandler] ...Dispatching MonitorTvEpisodeCommand'); $this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle());
} }
} }

View File

@@ -2,14 +2,12 @@
namespace App\Monitor\Framework\Controller; namespace App\Monitor\Framework\Controller;
use App\Base\Util\Broadcaster;
use App\Monitor\Action\Handler\AddMonitorHandler; use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler; use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput; use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput; use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Scheduler\MonitorDispatcher; use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -52,17 +50,4 @@ class ApiController extends AbstractController
'message' => $response 'message' => $response
]); ]);
} }
#[Route('/api/monitor/dispatch', name: 'api_monitor_dispatch', methods: ['GET'])]
public function dispatch(MonitorDispatcher $dispatcher, Broadcaster $broadcaster): Response
{
$dispatcher();
$broadcaster->alert('Success', 'The monitor job has been dispatched.');
return $this->json([
'status' => 200,
'message' => 'Manually dispatched MonitorDispatcher'
]);
}
} }

View File

@@ -4,7 +4,6 @@ namespace App\Monitor\Framework\Entity;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use Carbon\Carbon;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -50,9 +49,6 @@ class Monitor
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $searchCount = null; private ?int $searchCount = null;
#[ORM\Column]
private bool $onlyFuture = true;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null; private ?\DateTimeInterface $lastSearch = null;
@@ -150,14 +146,9 @@ class Monitor
return $this; return $this;
} }
public function isOnlyFuture(): bool
{
return $this->onlyFuture;
}
public function getLastSearch(): ?\DateTimeInterface public function getLastSearch(): ?\DateTimeInterface
{ {
return Carbon::parse($this->lastSearch); return $this->lastSearch;
} }
public function setLastSearch(?\DateTimeInterface $lastSearch): static public function setLastSearch(?\DateTimeInterface $lastSearch): static

View File

@@ -2,8 +2,8 @@
namespace App\Monitor\Framework\Repository; namespace App\Monitor\Framework\Repository;
use App\Base\Util\Paginator;
use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Entity\Monitor;
use App\Util\Paginator;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;

View File

@@ -7,7 +7,6 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand; use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Command\MonitorTvShowCommand; use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Framework\Repository\MonitorRepository;
use Carbon\Carbon;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask; use Symfony\Component\Scheduler\Attribute\AsCronTask;
@@ -24,8 +23,6 @@ class MonitorDispatcher
public function __invoke() { public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher'); $this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$this->cleanupStuckMonitors();
$monitorHandlers = [ $monitorHandlers = [
'movie' => MonitorMovieCommand::class, 'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class, 'tvepisode' => MonitorTvEpisodeCommand::class,
@@ -44,18 +41,4 @@ class MonitorDispatcher
$this->bus->dispatch(new $command($monitor->getId())); $this->bus->dispatch(new $command($monitor->getId()));
} }
} }
private function cleanupStuckMonitors(): void
{
$hoursStuck = 4;
$monitors = $this->monitorRepository->findBy(['status' => 'In Progress']);
foreach ($monitors as $monitor) {
// Reset the status to active so it will be executed again
if ($monitor->getLastSearch()->diffInHours(Carbon::today()) > $hoursStuck) {
$this->logger->info('[MonitorDispatcher] Cleaning up monitor: ' . $monitor->getId() . ' (' . $monitor->getTitle() . '), resetting status to \'Active\' from \''. $monitor->getStatus() .'\' after being stuck for ' . $hoursStuck . ' hours.');
$monitor->setStatus('Active');
}
}
$this->monitorRepository->getEntityManager()->flush();
}
} }

View File

@@ -2,6 +2,8 @@
namespace App\Search\Action\Input; namespace App\Search\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Enum\MediaType;
use App\Search\Action\Command\GetMediaInfoCommand; use App\Search\Action\Command\GetMediaInfoCommand;
use OneToMany\RichBundle\Attribute\SourceRoute; use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;

View File

@@ -1,38 +0,0 @@
<?php
namespace App\Tmdb\Framework\Controller;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
#[Route('/api/tmdb/ajax-search', name: 'api_tmdb_ajax_search', methods: ['GET'])]
public function test(Tmdb $tmdb, Request $request): Response
{
$results = [];
$term = $request->query->get('query') ?? null;
if (null !== $term) {
$tmdbResults = $tmdb->search($term);
foreach ($tmdbResults as $tmdbResult) {
/** @var TmdbResult $tmdbResult */
$results[] = [
'data' => $tmdbResult,
'text' => $tmdbResult->title,
'value' => "$tmdbResult->mediaType|$tmdbResult->imdbId",
];
}
}
return $this->json([
'results' => $results,
]);
}
}

View File

@@ -3,11 +3,14 @@
namespace App\Tmdb; namespace App\Tmdb;
use Aimeos\Map; use Aimeos\Map;
use App\Base\Enum\MediaType; use App\Enum\MediaType;
use App\ValueObject\ResultFactory; use App\ValueObject\ResultFactory;
use Carbon\Carbon;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Api\Find; use Tmdb\Api\Find;
use Tmdb\Client; use Tmdb\Client;
@@ -17,6 +20,7 @@ use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener; use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener; use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener; use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\Listener\RequestListener;
use Tmdb\Event\RequestEvent; use Tmdb\Event\RequestEvent;
use Tmdb\Model\Movie; use Tmdb\Model\Movie;
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery; use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
@@ -40,7 +44,7 @@ class Tmdb
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500"; const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct( public function __construct(
private readonly CacheItemPoolInterface $cache, private readonly CacheItemPoolInterface $tmdbCache,
private readonly EventDispatcherInterface $eventDispatcher, private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey, #[Autowire(env: 'TMDB_API')] string $apiKey,
) { ) {
@@ -74,7 +78,7 @@ class Tmdb
$requestListener = new Psr6CachedRequestListener( $requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(), $this->client->getHttpClient(),
$this->eventDispatcher, $this->eventDispatcher,
$cache, $tmdbCache,
$this->client->getHttpClient()->getPsr17StreamFactory(), $this->client->getHttpClient()->getPsr17StreamFactory(),
[] []
); );
@@ -321,7 +325,7 @@ class Tmdb
public function getImdbId(string $tmdbId, $mediaType) public function getImdbId(string $tmdbId, $mediaType)
{ {
$externalIds = $this->cache->get("tmdb.externalIds.{$tmdbId}", $externalIds = $this->tmdbCache->get("tmdb.externalIds.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) { function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) { switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value: case MediaType::Movie->value:
@@ -342,7 +346,7 @@ class Tmdb
public function getImages($tmdbId, $mediaType) public function getImages($tmdbId, $mediaType)
{ {
return $this->cache->get("tmdb.images.{$tmdbId}", return $this->tmdbCache->get("tmdb.images.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) { function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) { switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value: case MediaType::Movie->value:

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Torrentio\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class DeleteCacheCommand implements CommandInterface
{
public function __construct(
public ?string $type = null,
public ?string $mediaType = null,
public ?string $tmdbId = null,
public ?string $imdbId = null,
public ?int $season = null,
public ?int $episode = null,
public ?array $tags = null,
) {}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Torrentio\Action\Handler;
use Aimeos\Map;
use App\Torrentio\Action\Command\DeleteCacheCommand;
use App\Torrentio\Action\Result\DeleteCacheResult;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/** @implements HandlerInterface<DeleteCacheCommand, DeleteCacheResult> */
class DeleteCacheHandler implements HandlerInterface
{
public function __construct(
private readonly TagAwareCacheInterface $torrentioCache
) {}
public function handle(CommandInterface $command): ResultInterface
{
$input = Map::from((array) $command)
->filter(fn ($value, $key) => null !== $value && "" !== $value)
;
$cacheKey = null;
if ($input->has('type')) {
$cacheKey = $input->get('type');
if ($input->has('mediaType')) {
$cacheKey .= ".".$input->get('mediaType');
if ($input->has('tmdbId')) {
$cacheKey .= ".".$input->get('tmdbId');
if ($input->has('imdbId')) {
$cacheKey .= ".".$input->get('imdbId');
if ($input->has('season')) {
$cacheKey .= ".".$input->get('season');
if ($input->has('episode')) {
$cacheKey .= ".".$input->get('episode');
}
}
}
}
}
}
if ($cacheKey !== null) {
$this->torrentioCache->invalidateTags([$cacheKey]);
}
if ($input->has('tags')) {
$this->torrentioCache->invalidateTags($input->get('tags'));
}
return new DeleteCacheResult($input, $cacheKey, $command->tags);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Torrentio\Action\Input;
use App\Torrentio\Action\Command\DeleteCacheCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements DeleteCacheInput<DeleteCacheCommand>
*/
class DeleteCacheInput implements InputInterface
{
public function __construct(
#[SourceRequest('type', nullify: true)]
public ?string $type,
#[SourceRequest('mediaType', nullify: true)]
public ?string $mediaType,
#[SourceRequest('tmdbId', nullify: true)]
public ?string $tmdbId,
#[SourceRequest('imdbId', nullify: true)]
public ?string $imdbId,
#[SourceRequest('season', nullify: true)]
public ?int $season,
#[SourceRequest('episode', nullify: true)]
public ?int $episode,
#[SourceRequest('tags', nullify: true)]
public ?array $tags,
) {}
public function toCommand(): CommandInterface
{
return new DeleteCacheCommand(
type: $this->type,
mediaType: $this->mediaType,
tmdbId: $this->tmdbId,
imdbId: $this->imdbId,
season: $this->season,
episode: $this->episode
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Torrentio\Action\Result;
use Aimeos\Map;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteCacheResult implements ResultInterface
{
public function __construct(
public Map $result,
public ?string $cacheKey = null,
public ?array $tags = null,
) {}
}

View File

@@ -23,7 +23,7 @@ class Torrentio
public function __construct( public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey, #[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache, private TagAwareCacheInterface $torrentioCache,
private LoggerInterface $logger, private LoggerInterface $logger,
) { ) {
$this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl); $this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
@@ -32,11 +32,11 @@ class Torrentio
]); ]);
} }
public function search(string $imdbCode, string $type, bool $parseResults = true): array public function search(string $imdbCode, string $type, array $filter = []): array
{ {
$cacheKey = "torrentio.{$imdbCode}"; $cacheKey = "torrentio.{$imdbCode}";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) { $results = $this->torrentioCache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['torrentio', $type, $imdbCode]); $item->tag(['torrentio', $type, $imdbCode]);
try { try {
@@ -56,17 +56,13 @@ class Torrentio
return []; return [];
}); });
if (true === $parseResults) { return $this->parse($results, $filter);
return $this->parse($results);
}
return $results;
} }
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array
{ {
$cacheKey = "torrentio.$imdbId.$season.$episode"; $cacheKey = "torrentio.$imdbId.$season.$episode";
$results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) { $results = $this->torrentioCache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]); $item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]);
try { try {
@@ -90,15 +86,18 @@ class Torrentio
throw new TorrentioRateLimitException(); throw new TorrentioRateLimitException();
} }
if (true === $parseResults) { return $this->parse($results, []);
return $this->parse($results);
}
return $results;
} }
public function parse(array $data): array public function parse(array $data, array $filter): array
{ {
$ruleEngine = new RuleEngine();
foreach ($filter as $rule => $value) {
if ('resolution' === $rule) {
$ruleEngine->addRule(new Resolution($value));
}
}
$results = []; $results = [];
foreach ($data['streams'] as $stream) { foreach ($data['streams'] as $stream) {
if (!str_starts_with($stream['url'], "https")) { if (!str_starts_with($stream['url'], "https")) {
@@ -120,7 +119,9 @@ class Torrentio
$bingeGroup $bingeGroup
); );
$results[] = $result; if ($ruleEngine->validateAll($result)) {
$results[] = $result;
}
} }
return $results; return $results;

View File

@@ -2,27 +2,20 @@
namespace App\Torrentio\Framework\Controller; namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Client\Torrentio; use App\Torrentio\Action\Handler\DeleteCacheHandler;
use App\Torrentio\Action\Input\DeleteCacheInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class ApiController extends AbstractController class ApiController extends AbstractController
{ {
public function __construct( #[Route('/api/torrentio/cache', name: 'api.torrentio.cache', methods: ['POST'])]
private readonly Torrentio $torrentio, public function deleteCache(
) {} DeleteCacheInput $deleteCacheInput,
DeleteCacheHandler $deleteCacheHandler,
#[Route('/api/torrentio/{imdbId}/{season?}/{episode?}', name: 'api_torrentio')] ): Response {
public function api(string $imdbId, ?int $season, ?int $episode): Response $result = $deleteCacheHandler->handle($deleteCacheInput->toCommand());
{ return $this->json($result, Response::HTTP_OK);
if (null !== $season && null !== $episode) {
return $this->json(
$this->torrentio->fetchEpisodeResults($imdbId, $season, $episode, false)
);
}
return $this->json(
$this->torrentio->search($imdbId, 'movies', false),
);
} }
} }

View File

@@ -2,7 +2,8 @@
namespace App\Torrentio\Result; namespace App\Torrentio\Result;
use App\Base\Util\CountryLanguages; use App\Util\CountryCodes;
use App\Util\CountryLanguages;
use Nihilarr\PTN; use Nihilarr\PTN;
class ResultFactory class ResultFactory
@@ -21,7 +22,6 @@ class ResultFactory
string $bingeGroup = "-" string $bingeGroup = "-"
) { ) {
$ptn = (object) (new PTN())->parse($title); $ptn = (object) (new PTN())->parse($title);
// dump($ptn);
return new TorrentioResult( return new TorrentioResult(
self::trimTitle($title), self::trimTitle($title),
urldecode($url), urldecode($url),
@@ -34,7 +34,6 @@ class ResultFactory
$bingeGroup, $bingeGroup,
$ptn->resolution ?? "-", $ptn->resolution ?? "-",
self::setCodec($ptn->codec ?? "-"), self::setCodec($ptn->codec ?? "-"),
$ptn->quality ?? "-",
$ptn, $ptn,
substr(base64_encode($url), strlen($url) - 10), substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-", $ptn->episode ?? "-",

View File

@@ -16,7 +16,6 @@ class TorrentioResult
public ?string $bingeGroup = "-", public ?string $bingeGroup = "-",
public ?string $resolution = "-", public ?string $resolution = "-",
public ?string $codec = "-", public ?string $codec = "-",
public ?string $quality = "-",
public object|array $ptn = [], public object|array $ptn = [],
public ?string $key = "-", public ?string $key = "-",
public ?string $episodeNumber = "-", public ?string $episodeNumber = "-",

View File

@@ -1,10 +0,0 @@
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class ActionButton
{
}

View File

@@ -3,8 +3,11 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Paginator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;

View File

@@ -3,7 +3,6 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use Aimeos\Map; use Aimeos\Map;
use App\Base\Util\QualityList;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
@@ -18,8 +17,6 @@ final class Filter
public array $userPreferences = []; public array $userPreferences = [];
public array $reverseMappedQualities = [];
public function __construct( public function __construct(
private readonly PreferencesRepository $preferencesRepository, private readonly PreferencesRepository $preferencesRepository,
private readonly Security $security, private readonly Security $security,
@@ -30,6 +27,5 @@ final class Filter
->toArray(); ->toArray();
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues()) $this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
->toArray(); ->toArray();
$this->reverseMappedQualities = QualityList::getAsReverseMap();
} }
} }

View File

@@ -2,7 +2,7 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use App\Base\Util\Paginator; use App\Util\Paginator;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg; use Symfony\UX\LiveComponent\Attribute\LiveArg;

View File

@@ -10,7 +10,6 @@ class SaveUserMediaPreferencesCommand implements CommandInterface
public function __construct( public function __construct(
public string $resolution, public string $resolution,
public string $codec, public string $codec,
public string $quality,
public string $language, public string $language,
public string $provider, public string $provider,
) {} ) {}

View File

@@ -18,9 +18,6 @@ class SaveUserMediaPreferencesInput implements InputInterface
#[SourceRequest('resolution')] #[SourceRequest('resolution')]
public string $resolution, public string $resolution,
#[SourceRequest('quality')]
public string $quality,
#[SourceRequest('codec')] #[SourceRequest('codec')]
public string $codec, public string $codec,
@@ -36,7 +33,6 @@ class SaveUserMediaPreferencesInput implements InputInterface
return new SaveUserMediaPreferencesCommand( return new SaveUserMediaPreferencesCommand(
$this->resolution, $this->resolution,
$this->codec, $this->codec,
$this->quality,
$this->language, $this->language,
$this->provider, $this->provider,
); );

View File

@@ -1,15 +0,0 @@
<?php
namespace App\User\Dto;
class UserPreferences
{
public function __construct(
public readonly ?string $resolution,
public readonly ?string $codec,
public readonly ?string $language,
public readonly ?string $provider,
public readonly ?string $quality,
) {}
}

View File

@@ -1,47 +0,0 @@
<?php
namespace App\User\Dto;
use App\User\Framework\Entity\PreferenceOption;
use App\User\Framework\Entity\User;
use Symfony\Component\Security\Core\User\UserInterface;
class UserPreferencesFactory
{
/** @param User $user */
public static function createFromUser(UserInterface $user): UserPreferences
{
return new UserPreferences(
resolution: self::getNestedValue($user, 'resolution'),
codec: self::getNestedValue($user, 'codec'),
language: self::getValue($user, 'language'),
provider: self::getValue($user, 'provider'),
quality: self::getValue($user, 'quality'),
);
}
/** @param User $user */
private static function getValue(UserInterface $user, string $preferenceId)
{
$value = $user->getUserPreference($preferenceId)->getPreferenceValue();
if ($value === "") {
return null;
}
return $value;
}
/** @param User $user */
private static function getNestedValue(UserInterface $user, string $preferenceId): ?string
{
$preference = $user->getUserPreference($preferenceId);
if (null === $preference) {
return null;
}
return $preference->getPreference()
->getPreferenceOptions()
->filter(fn (PreferenceOption $option) => (string) $option->getId() === $preference->getPreferenceValue())
->first()
->getValue()
;
}
}

View File

@@ -4,15 +4,14 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web; namespace App\User\Framework\Controller\Web;
use App\Base\Util\Broadcaster;
use App\Base\Util\CountryLanguages;
use App\Base\Util\ProviderList;
use App\Base\Util\QualityList;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler; use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler; use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput; use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput; use App\User\Action\Input\SaveUserMediaPreferencesInput;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use App\Util\Broadcaster;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -37,8 +36,7 @@ class PreferencesController extends AbstractController
[ [
'preferences' => $this->preferencesRepository->findEnabled(), 'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages, 'languages' => $languages,
'providers' => ProviderList::getProviders(), 'providers' => ProviderList::$providers,
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
] ]
@@ -69,7 +67,6 @@ class PreferencesController extends AbstractController
'preferences' => $this->preferencesRepository->findEnabled(), 'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages, 'languages' => $languages,
'providers' => ProviderList::$providers, 'providers' => ProviderList::$providers,
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
] ]
@@ -98,8 +95,7 @@ class PreferencesController extends AbstractController
[ [
'preferences' => $this->preferencesRepository->findEnabled(), 'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages, 'languages' => $languages,
'providers' => ProviderList::getProviders(), 'providers' => ProviderList::$providers,
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
] ]

View File

@@ -153,16 +153,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->userPreferences; return $this->userPreferences;
} }
public function getUserPreference(string $preferenceName): ?UserPreference public function getUserPreference(string $preferenceName)
{ {
foreach ($this->userPreferences as $userPreference) { foreach ($this->userPreferences as $userPreference) {
if ($userPreference->getPreference()->getName() === $preferenceName if ($userPreference->getPreference()->getName() === $preferenceName) {
|| $userPreference->getPreference()->getId() === $preferenceName return $userPreference->getPreference();
) {
return $userPreference;
} }
} }
return null;
} }
public function hasUserPreference(string $preferenceName): bool public function hasUserPreference(string $preferenceName): bool
@@ -212,7 +209,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return Map::from($this->userPreferences) return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId()) ->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->map(function (UserPreference $userPreference) { ->map(function (UserPreference $userPreference) {
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider', 'quality'])) { if (in_array($userPreference->getPreference()->getId(), ['language', 'provider'])) {
return $userPreference->getPreferenceValue(); return $userPreference->getPreferenceValue();
} }
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) { foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {

View File

@@ -2,8 +2,6 @@
namespace App\User\Framework\EventListener; namespace App\User\Framework\EventListener;
use App\Base\ConfigResolver;
use App\Base\Util\Broadcaster;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent; use Symfony\Component\Security\Core\Event\AuthenticationSuccessEvent;
@@ -12,26 +10,12 @@ final class LoginSuccessListener
{ {
public function __construct( public function __construct(
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly ConfigResolver $configResolver,
private readonly Broadcaster $broadcaster,
) {} ) {}
#[AsEventListener(event: 'security.authentication.success', priority: 20)] #[AsEventListener(event: 'security.authentication.success')]
public function setMercureTopics(AuthenticationSuccessEvent $event): void public function setMercureTopics(AuthenticationSuccessEvent $event): void
{ {
// Set the unique Mercure topic name for the User's alerts // Set the unique Mercure topic name for the User's alerts
$this->requestStack->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid()); $this->requestStack->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
} }
#[AsEventListener(event: 'security.authentication.success', priority: 10)]
public function validateConfig(AuthenticationSuccessEvent $event): void
{
// Set the unique Mercure topic name for the User's alerts
$valid = $this->configResolver->validate();
if (false === $valid) {
foreach ($this->configResolver->getMessages() as $message) {
$this->requestStack->getSession()->getFlashBag()->add('warning', $message);
}
}
}
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\Util;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\Util;
class CountryCodes class CountryCodes
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\Util;
class CountryLanguages class CountryLanguages
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\Util;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Base\Util; namespace App\Util;
class ProviderList class ProviderList
{ {

View File

@@ -281,18 +281,6 @@
"templates/base.html.twig" "templates/base.html.twig"
] ]
}, },
"symfony/ux-autocomplete": {
"version": "2.27",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.6",
"ref": "07d9602b7231ba355f484305d6cea58310c01741"
},
"files": [
"config/routes/ux_autocomplete.yaml"
]
},
"symfony/ux-icons": { "symfony/ux-icons": {
"version": "2.24", "version": "2.24",
"recipe": { "recipe": {

View File

@@ -5,11 +5,6 @@ module.exports = {
"./templates/**/*.html.twig", "./templates/**/*.html.twig",
], ],
safelist: [ safelist: [
"flex",
"flex-col",
"flex-row",
"p-2",
"p-4",
"bg-blue-300", "bg-blue-300",
"bg-orange-300", "bg-orange-300",
"bg-fuchsia-300", "bg-fuchsia-300",
@@ -26,13 +21,7 @@ module.exports = {
"transition-opacity", "transition-opacity",
"ease-in", "ease-in",
"duration-700", "duration-700",
"opacity-100", "opacity-100"
"table-row",
"max-w-[60ch]",
"truncate",
"text-wrap",
"rounded-sm",
"rounded-md"
], ],
theme: { theme: {
extend: { extend: {

View File

@@ -2,7 +2,6 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Welcome!{% endblock %}</title> <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>"> <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 %} {% block stylesheets %}

View File

@@ -2,7 +2,6 @@
<html> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Welcome!{% endblock %}</title> <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>"> <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 %} {% block stylesheets %}
@@ -14,15 +13,15 @@
{% endblock %} {% endblock %}
</head> </head>
<body class="flex flex-col bg-stone-700"> <body class="flex flex-col bg-stone-700">
<div class="grid md:grid-cols-6"> <div class="grid grid-cols-6">
<div class="hidden md:block md:col-span-1 md:h-screen"> <div class="col-span-1 h-screen">
<twig:NavBar /> <twig:NavBar />
</div> </div>
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll"> <div class="col-span-5 h-screen overflow-y-scroll">
<twig:Header /> <twig:Header />
<div class="flex justify-between items-center"> <div class="px-4 mt-3 flex flex-row justify-between">
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <h2 class="m-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex mt-4 gap-2 items-center grow-0 md:px-4"> <div class="flex flex-row gap-1 align-end justify-end items-end">
{% block action_buttons %}{% endblock %} {% block action_buttons %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -1,13 +0,0 @@
<button
class="h-6 bg-{{ color|default('orange') }}-500/80 hover:bg-{{ color|default('orange') }}-600/80 px-2 text-{{ text_color|default('white') }} rounded-ms text-sm font-semibold"
{% if custom_controller|default and custom_action|default %}
{{ attributes.defaults(stimulus_controller(custom_controller, custom_controller_vars|default({}))) }}
{{ stimulus_action(custom_controller, custom_action|default('default'), custom_action_event|default('click'), custom_action_params|default({})) }}
{% else %}
{{ attributes.defaults(stimulus_controller('action_button')) }}
{{ stimulus_action('action_button', action|default('default')) }}
{% endif %}
>
{{ text|default('button') }}
</button>

View File

@@ -7,9 +7,20 @@
<path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/> <path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z"/>
</svg> </svg>
<span class="sr-only">Info</span> <span class="sr-only">Info</span>
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3> <h3 class="text-lg font-medium">{{ title|default('') }}</h3>
</div> </div>
<div class="mt-2 text-sm w-[350px] font-bold"> <div class="mt-2 text-sm">
{{ message }} {{ message }}
</div> </div>
{# <div class="flex">#}
{# <button type="button" class="text-white bg-green-800 hover:bg-green-900 focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-xs px-3 py-1.5 me-2 text-center inline-flex items-center dark:bg-green-600 dark:hover:bg-green-700 dark:focus:ring-green-800">#}
{# <svg class="me-2 h-3 w-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 14">#}
{# <path d="M10 0C4.612 0 0 5.336 0 7c0 1.742 3.546 7 10 7 6.454 0 10-5.258 10-7 0-1.664-4.612-7-10-7Zm0 10a3 3 0 1 1 0-6 3 3 0 0 1 0 6Z"/>#}
{# </svg>#}
{# View more#}
{# </button>#}
{# <button type="button" class="text-green-800 bg-transparent border border-green-800 hover:bg-green-900 hover:text-white focus:ring-4 focus:outline-none focus:ring-green-300 font-medium rounded-lg text-xs px-3 py-1.5 text-center dark:hover:bg-green-600 dark:border-green-600 dark:text-green-400 dark:hover:text-white dark:focus:ring-green-800" data-dismiss-target="#alert-additional-content-3" aria-label="Close">#}
{# Dismiss#}
{# </button>#}
{# </div>#}
</li> </li>

View File

@@ -1,6 +1,6 @@
<div{{ attributes }}> <div{{ attributes }}>
<div class="flex flex-col bg-sky-950 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl <div class="flex flex-col bg-sky-950 border-neutral-700 border-t-4 border-t-orange-500 rounded-xl
backdrop-filter backdrop-blur-md bg-opacity-40 z-10 backdrop-filter backdrop-blur-md bg-opacity-40
"> ">
<div class="p-4 md:p-5"> <div class="p-4 md:p-5">
<h3 class="mb-4 text-lg font-bold text-white"> <h3 class="mb-4 text-lg font-bold text-white">

View File

@@ -11,17 +11,21 @@
<thead> <thead>
<tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950"> <tr class="bg-orange-500 bg-filter bg-blur-lg bg-opacity-80 text-gray-950">
<th scope="col" <th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate"> class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 {% if this.isWidget == true %}min-w-[45ch] max-w-[45ch]{% endif %} truncate">
Title Title
</th> </th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}"> {% if this.isWidget == false %}
Filename <th scope="col"
</th> class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate">
<th scope="col" Filename
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}"> </th>
Media type <th scope="col"
</th> class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate">
Media type
</th>
{% endif %}
<th scope="col" <th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800"> class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-stone-800">
Progress Progress
@@ -34,7 +38,7 @@
<tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50"> <tbody id="{{ table_body_id }}" class="divide-y divide-gray-200 dark:divide-gray-50">
{% if this.downloads.items|length > 0 %} {% if this.downloads.items|length > 0 %}
{% for download in this.downloads.items %} {% for download in this.downloads.items %}
<twig:DownloadListRow download="{{ download }}" isWidget="{{ isWidget }}" /> <twig:DownloadListRow download="{{ download }}" isWidget="{{ this.isWidget }}" />
{% endfor %} {% endfor %}
{% if this.isWidget == true and this.downloads.items|length > this.perPage %} {% if this.isWidget == true and this.downloads.items|length > this.perPage %}
<tr id="download_view_all"> <tr id="download_view_all">

View File

@@ -1,7 +1,7 @@
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}"> <tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}" <a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}"
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate" class="mr-1 hover:underline rounded-md"
> >
{{ download.title }} {{ download.title }}
</a> </a>
@@ -11,13 +11,14 @@
{% endif %} {% endif %}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate"> {% if isWidget == false %}
{{ download.filename }} <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] truncate">
</td> {{ download.filename }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate {{ isWidget == true ? "hidden" : "r-tablecell" }}"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
{{ download.mediaType }} {{ download.mediaType }}
</td> </td>
{% endif %}
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50"> <td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %} {% if download.progress < 100 %}
@@ -31,7 +32,7 @@
<twig:StatusBadge color="green" status="Complete" /> <twig:StatusBadge color="green" status="Complete" />
{% endif %} {% endif %}
</td> </td>
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center"> <td id="action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center">
{% if download.status == 'In Progress' and download.progress < 100 %} {% if download.status == 'In Progress' and download.progress < 100 %}
<button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}> <button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}>
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" /> <twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />

View File

@@ -1,12 +1,12 @@
<div id="filter" class="flex flex-col gap-4" <div id="filter" class="flex flex-col gap-4"
{{ stimulus_controller('result_filter', {reverseMappedQualities: this.reverseMappedQualities, imdbId: results.media.imdbId}) }} {{ stimulus_controller('result_filter') }}
{{ stimulus_action('result_filter', 'filter', 'change') }}
data-result-filter-media-type-value="{{ results.media.mediaType }}" data-result-filter-media-type-value="{{ results.media.mediaType }}"
data-result-filter-movie-results-outlet=".results" data-result-filter-movie-results-outlet=".results"
data-result-filter-tv-results-outlet=".results" data-result-filter-tv-results-outlet=".results"
data-result-filter-tv-episode-list-outlet=".episode-list" data-result-filter-tv-episode-list-outlet=".episode-list"
data-action="change->result-filter#filter movie-results:optionsLoaded@window->result-filter#loadOptions tv-results:optionsLoaded@window->result-filter#loadOptions action-button:downloadSeason@window->result-filter#downloadSeason"
> >
<div class="w-full p-4 flex flex-col md:flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg"> <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"> <label for="resolution">
Resolution Resolution
<select id="resolution" <select id="resolution"
@@ -55,17 +55,6 @@
> >
</select> </select>
</label> </label>
<label for="quality">
Quality
<select id="quality"
data-result-filter-target="quality"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{% if this.userPreferences['quality'] != null %}
data-preferred="{{ this.userPreferences['quality'] }}"
{% endif %}
>
</select>
</label>
{% if results.media.mediaType == "tvshows" %} {% if results.media.mediaType == "tvshows" %}
<label for="season"> <label for="season">
Season Season
@@ -94,19 +83,10 @@
{% if results.media.mediaType == "tvshows" %} {% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row gap-2 justify-end px-8"> <div class="flex flex-row gap-2 justify-end px-8">
<twig:Modal heading="Back up a sec!" button_text="Download Season" submit_action="{{ stimulus_action('result_filter', 'downloadSeason', 'click')|stimulus_action('dialog', 'close') }}" button_class="px-1.5 py-1 bg-green-600 rounded-ms text-sm font-semibold" show_cancel show_submit> <button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-md text-sm"
Downloading an entire season this way will use the filter from your
<a href="{{ path('app_user_preferences') }}" class="text-underline">preferences</a> to choose
the appropriate file(s).
<br /><br />
Do you wish to download <strong>season {{ results.season }}</strong> of "<strong>{{ results.media.title }}</strong>"?
</twig:Modal>
<button class="px-1.5 py-1 bg-green-600 hover:bg-green-700 rounded-ms text-sm font-semibold"
{{ stimulus_target('result_filter', 'downloadSelected') }} {{ stimulus_target('result_filter', 'downloadSelected') }}
{{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }} {{ stimulus_action('result_filter', 'downloadSelectedEpisodes', 'click') }}
>Download Selected</button> >Download Selected</button>
<input type="checkbox" name="selectAll" id="selectAll" <input type="checkbox" name="selectAll" id="selectAll"
{{ stimulus_target('result_filter', 'selectAll') }} {{ stimulus_target('result_filter', 'selectAll') }}
{{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }} {{ stimulus_action('result_filter', 'selectAllEpisodes', 'change') }}

View File

@@ -1,26 +1,16 @@
<header {{ attributes }} class="bg-cyan-950 z-40"> <header {{ attributes }} class="bg-cyan-950 z-40">
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-4 sm:px-6 lg:px-8">
<div class="h-16 flex flex-row items-center justify-between"> <div class="h-16 flex flex-row items-center justify-between">
<a href="{{ path('app_index') }}" class="text-2xl text-orange-500 mr-4 md:hidden">T</a>
<twig:SearchBar /> <twig:SearchBar />
<div class="md:flex md:items-center md:gap-12"> <div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block"> <nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm"> <ul class="flex items-center gap-6 text-sm">
<li class="hidden"> <li><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>
<li class="hidden md:block">
<a href="{{ path('app_logout') }}"> <a href="{{ path('app_logout') }}">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" /> <twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a> </a>
</li> </li>
<li {{ stimulus_controller('hamburger') }}
{{ stimulus_action('hamburger', 'toggleMenu', 'click') }}
data-hamburger-navbar-outlet="#navbar"
id="hamburger" class="cursor-pointer md:hidden"
>
<svg xmlns="http://www.w3.org/2000/svg" class="text-orange-500 ml-4" width="25px" height="25px" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M0 3.75A.75.75 0 0 1 .75 3h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 3.75M0 8a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8m.75 3.5a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>
@@ -28,10 +18,7 @@
</div> </div>
<div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="fixed z-40 top-10 right-10"> <div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="fixed z-40 top-10 right-10">
<div class="z-40"> <div class="z-40">
<ul id="alert_list" class="flex flex-col gap-2"> <ul id="alert_list">
{% for message in app.flashes('warning') %}
<twig:Alert :title="'Warning'" :message="message" :alert_id="''" type="warning" data-controller="alert" />
{% endfor %}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
<div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose" class="modal flex flex-row items-center {{ container_class|default('') }}"> <div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose" class="flex flex-row items-center">
<dialog data-dialog-target="dialog" class="py-3 px-4 w-[30rem] rounded-md"> <dialog data-dialog-target="dialog" class="py-3 px-4 w-[30rem] rounded-md">
<h2 class="mb-4 text-2xl font-bold">{{ heading }}</h2> <h2 class="mb-4 text-2xl font-bold">{{ heading }}</h2>
@@ -9,12 +9,12 @@
{% if show_cancel is defined or show_submit is defined %} {% if show_cancel is defined or show_submit is defined %}
<div class="flex justify-end"> <div class="flex justify-end">
{% if show_cancel is defined %} {% if show_cancel is defined %}
<button type="button" data-action="dialog#close" class="px-1 py-1 rounded-md self-end w-16 hover:bg-stone-100" autofocus> <button type="button" data-action="dialog#close" class="secondary-btn" autofocus>
{{ cancel_text|default('Cancel') }} {{ cancel_text|default('Cancel') }}
</button> </button>
{% endif %} {% endif %}
{% if show_submit is defined %} {% if show_submit is defined %}
<button type="button" {{ submit_action|raw }} class="px-1 py-1 rounded-md bg-orange-500 self-end text-white w-16 ml-2 hover:bg-orange-600" autofocus> <button type="button" {{ submit_action|raw }} class="primary-btn" autofocus>
{{ submit_text|default('Submit') }} {{ submit_text|default('Submit') }}
</button> </button>
{% endif %} {% endif %}
@@ -22,5 +22,5 @@
{% endif %} {% endif %}
</dialog> </dialog>
<button type="button" class="{{ button_class|default('') }}" data-action="dialog#open">{{ button_text|raw }}</button> <button type="button" data-action="dialog#open" class="{{ button_class|default('') }}">{{ button_text|raw }}</button>
</div> </div>

View File

@@ -16,32 +16,32 @@
ID ID
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase"> class="px-6 py-3 text-start text-xs font-medium uppercase">
Search Count Search Count
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase"> class="px-6 py-3 text-start text-xs font-medium uppercase">
Created at Created at
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase"> class="px-6 py-3 text-start text-xs font-medium uppercase">
Last Search Date Last Search Date
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase"> class="px-6 py-3 text-start text-xs font-medium uppercase">
Type Type
</th> </th>
<th scope="col" <th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase"> class="px-6 py-3 text-start text-xs font-medium uppercase">
Status Status
</th> </th>
<th class="hidden md:table-cell"></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="monitors" class="divide-y divide-gray-50"> <tbody id="monitors" class="divide-y divide-gray-50">
{% if this.monitors.items|length > 0 %} {% if this.monitors.items|length > 0 %}
{% for monitor in this.monitors.items %} {% for monitor in this.monitors.items %}
<twig:MonitorListRow :monitor="monitor" isWidget="{{ this.isWidget }}" /> <twig:MonitorListRow :monitor="monitor" />
{% endfor %} {% endfor %}
{% if this.isWidget and this.monitors.items|length > 5 %} {% if this.isWidget and this.monitors.items|length > 5 %}
<tr id="monitor_view_all"> <tr id="monitor_view_all">

View File

@@ -9,16 +9,16 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor|monitor_media_id }} {{ monitor|monitor_media_id }}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }} {{ monitor.searchCount }}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }} {{ monitor.createdAt|date('m/d/Y h:i a') }}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }} {{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.monitorType == "tvshow" %} {% if monitor.monitorType == "tvshow" %}
<twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" /> <twig:StatusBadge color="blue" number="300" text="black" status="{{ monitor.monitorType|monitor_type }}" />
{% elseif monitor.monitorType == "tvseason" %} {% elseif monitor.monitorType == "tvseason" %}
@@ -36,7 +36,7 @@
<twig:StatusBadge color="green" status="{{ monitor.status }}" /> <twig:StatusBadge color="green" status="{{ monitor.status }}" />
{% endif %} {% endif %}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 flex flex-row align-middle justify-center"> <td class="px-6 py-4 flex flex-row align-middle justify-center">
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '18px', class: 'rounded-full align-middle text-red-600' }) %} {% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '18px', class: 'rounded-full align-middle text-red-600' }) %}
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('monitor_list', 'deleteMonitor', 'click', {id: monitor.id}) }}" show_cancel show_submit> <twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('monitor_list', 'deleteMonitor', 'click', {id: monitor.id}) }}" show_cancel show_submit>
Are you sure you want to delete this monitor?<br /> Are you sure you want to delete this monitor?<br />

View File

@@ -1,7 +1,7 @@
<nav id="navbar" {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950 animate__animated animate__slideInLeft animate__slow"> <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-4 flex flex-col gap-12"> <div class="px-4 py-4 flex flex-col gap-12">
<h1 class="text-3xl mt-12 md:mt-0 font-extrabold text-orange-500 mb-3"><a href="{{ path('app_index') }}">Torsearch</a></h1> <h1 class="text-3xl font-extrabold text-orange-500 mb-3">Torsearch</h1>
<ul class="nav-list space-y-1"> <ul class="space-y-1">
<li> <li>
<a href="{{ path('app_index') }}" <a href="{{ path('app_index') }}"
class="block rounded-lg class="block rounded-lg

View File

@@ -3,12 +3,12 @@
mediaType: mediaType, mediaType: mediaType,
imdbId: imdbId imdbId: imdbId
}) }}"> }) }}">
<img src="{{ image }}" class="w-full md:w-40 rounded-md" /> <img src="{{ image }}" class="w-40 rounded-md" />
</a> </a>
<a href="{{ path('app_search_result', { <a href="{{ path('app_search_result', {
mediaType: mediaType, mediaType: mediaType,
imdbId: imdbId imdbId: imdbId
}) }}"> }) }}">
<h3 class="text-center text-white text-xl md:text-base md:max-w-[16ch]">{{ title }}</h3> <h3 class="text-center text-gray-50 max-w-[16ch] text-extrabold">{{ title }}</h3>
</a> </a>
</div> </div>

View File

@@ -1,24 +1,15 @@
<div {{ attributes }} class="w-full max-w-sm min-w-[200px]"> <div {{ attributes }} class="w-full max-w-sm min-w-[200px]">
<div class="relative"> <div class="relative">
<form id="search" action="{{ path('app_search') }}"> <form action="{{ path('app_search') }}">
<select <input
{{ stimulus_controller('search_bar')|stimulus_controller('symfony/ux-autocomplete/autocomplete', {
url: path('api_tmdb_ajax_search'),
create: false,
tomSelectOptions: {
highlight: false,
}
}) }}
id="term"
name="term" name="term"
class="w-full bg-orange-500 rounded-md bg-clip-padding backdrop-filter class="w-full bg-orange-500 rounded-md bg-clip-padding backdrop-filter
backdrop-blur-md bg-opacity-40 placeholder:text-slate-200 text-gray-50 backdrop-blur-md bg-opacity-40 placeholder:text-slate-200 text-gray-50
text-sm border border-orange-500 rounded-md pl-3 pr-28 py-0 transition text-sm border border-orange-500 rounded-md pl-3 pr-28 py-2 transition
duration-300 ease focus:outline-none focus:border-orange-400 hover:border-orange-300 duration-300 ease focus:outline-none focus:border-orange-400 hover:border-orange-300
shadow-sm focus:shadow ts-search z-40" shadow-sm focus:shadow"
placeholder="TV Show, Movie..." placeholder="TV Show, Movie..."
> />
</select>
<button <button
class="absolute top-1 right-1 flex items-center rounded class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center bg-green-600 py-1 px-2.5 border border-transparent text-center
@@ -27,7 +18,7 @@
text-white bg-green-600 text-sm text-white bg-green-600 text-sm
border border-green-500 border border-green-500
backdrop-filter backdrop-blur-md bg-opacity-80 z-40 backdrop-filter backdrop-blur-md bg-opacity-80
" "
type="submit" type="submit"
> >

View File

@@ -1,9 +1,9 @@
<div{{ attributes }}> <div{{ attributes }}>
<div class="p-4 flex flex-col md:flex-row gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md"> <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">
{% if poster != null and poster != "https://image.tmdb.org/t/p/w500" %} {% if poster != null and poster != "https://image.tmdb.org/t/p/w500" %}
<img class="w-full md:w-24 rounded-lg" src="{{ poster }}" /> <img class="w-24 rounded-lg" src="{{ poster }}" />
{% else %} {% else %}
<div class="w-full md:w-32 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center"> <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" /> <twig:ux:icon width="16" name="hugeicons:loading-01" />
</div> </div>
{% endif %} {% endif %}
@@ -12,11 +12,11 @@
<h3 class="mb-4 text-xl font-medium leading-tight font-bold text-gray-50"> <h3 class="mb-4 text-xl font-medium leading-tight font-bold text-gray-50">
{{ title }} - {{ year }} {{ title }} - {{ year }}
</h3> </h3>
<p class="hidden md:block md:text-gray-50"> <p class="text-gray-50">
{{ description }} {{ description }}
</p> </p>
</div> </div>
<a class="h-9 rounded-md py-1 px-2 bg-green-600 text-gray-50 text-center" <a class="h-9 rounded-md py-1 px-2 bg-green-600 text-gray-50"
href="{{ path('app_search_result', {mediaType: mediaType, imdbId: imdbId}) }}" href="{{ path('app_search_result', {mediaType: mediaType, imdbId: imdbId}) }}"
>choose</a> >choose</a>
</div> </div>

View File

@@ -15,12 +15,12 @@
active: 'true', active: 'true',
}) }} }) }}
> >
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md"> <div class="p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-row gap-4">
{% if episode['poster'] != null %} {% if episode['poster'] != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" /> <img class="w-64 rounded-lg" src="{{ episode['poster'] }}" />
{% else %} {% else %}
<div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center"> <div class="w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" /> <twig:ux:icon width="32" name="hugeicons:loading-01" />
</div> </div>
{% endif %} {% endif %}
@@ -37,7 +37,7 @@
</button> </button>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Air date {{ episode['name'] }}"> <small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Air date {{ episode['name'] }}">
{{ episode['air_date']|date(null, 'UTC') }} {{ episode['air_date']|date }}
</small> </small>
</div> </div>
</div> </div>

View File

@@ -4,13 +4,12 @@
{% block h2 %}Downloads{% endblock %} {% block h2 %}Downloads{% endblock %}
{% block body %} {% block body %}
<div class="px-4 py-2"> <div class="p-4">
<twig:Card title="Active Downloads"> <twig:Card title="Active Downloads">
<twig:DownloadList type="active" :isWidget="false" :perPage="10"></twig:DownloadList> <twig:DownloadList type="active" :isWidget="false" :perPage="10"></twig:DownloadList>
</twig:Card> </twig:Card>
</div> </div>
<div class="p-4">
<div class="px-4 py-2">
<twig:Card title="Recent Downloads"> <twig:Card title="Recent Downloads">
<twig:DownloadList type="complete" :isWidget="false" :perPage="10"></twig:DownloadList> <twig:DownloadList type="complete" :isWidget="false" :perPage="10"></twig:DownloadList>
</twig:Card> </twig:Card>

View File

@@ -4,8 +4,8 @@
{% block h2 %}Dashboard{% endblock %} {% block h2 %}Dashboard{% endblock %}
{% block body %} {% block body %}
<div class="p-4 flex flex-col grow gap-4 z-10"> <div class="p-4 flex flex-col grow gap-4 z-30">
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-row gap-4">
<twig:Card title="Active Downloads" class="w-full"> <twig:Card title="Active Downloads" class="w-full">
<twig:DownloadList :type="'active'" /> <twig:DownloadList :type="'active'" />
</twig:Card> </twig:Card>
@@ -14,13 +14,13 @@
<twig:DownloadList :type="'complete'" /> <twig:DownloadList :type="'complete'" />
</twig:Card> </twig:Card>
</div> </div>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-row gap-4">
<twig:Card title="Monitors" class="w-full"> <twig:Card title="Monitors" class="w-full">
<twig:MonitorList :type="'active'" :isWidget="true" /> <twig:MonitorList :type="'active'" :isWidget="true" />
</twig:Card> </twig:Card>
</div> </div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-col gap-4 md:flex-row md:justify-between w-full"> <twig:Card title="Popular Movies" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_movies %} {% for movie in popular_movies %}
<twig:Poster imdbId="{{ movie.imdbId }}" <twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}" tmdbId="{{ movie.tmdbId }}"
@@ -32,7 +32,7 @@
/> />
{% endfor %} {% endfor %}
</twig:Card> </twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-col md:flex-row justify-between w-full"> <twig:Card title="Popular TV Shows" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_tvshows %} {% for movie in popular_tvshows %}
<twig:Poster imdbId="{{ movie.imdbId }}" <twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}" tmdbId="{{ movie.tmdbId }}"

View File

@@ -3,18 +3,14 @@
{% block title %}Monitors &mdash; Torsearch{% endblock %} {% block title %}Monitors &mdash; Torsearch{% endblock %}
{% block h2 %}Monitors{% endblock %} {% block h2 %}Monitors{% endblock %}
{% block action_buttons %}
<twig:ActionButton action="monitorDispatch" text="Run Monitors" />
{% endblock %}
{% block body %} {% block body %}
<div class="px-4 py-2"> <div class="p-4">
<twig:Card title="Active Monitors"> <twig:Card title="Active Monitors" class="w-full">
<twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList> <twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card> </twig:Card>
</div> </div>
<div class="px-4 py-2"> <div class="p-4">
<twig:Card title="Complete Monitors"> <twig:Card title="Complete Monitors" class="w-full">
<twig:MonitorList :type="'complete'" :isWidget="false" :perPage="10"></twig:MonitorList> <twig:MonitorList :type="'complete'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card> </twig:Card>
</div> </div>

View File

@@ -6,12 +6,12 @@
<div class="p-4 flex flex-col grow gap-4"> <div class="p-4 flex flex-col grow gap-4">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2> <h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2>
<div class="flex flex-row w-full gap-2"> <div class="flex flex-row w-full gap-2">
<twig:Card title="" class="w-full" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50"> <twig:Card title="" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6"> <div class="p-4 flex flex-row gap-6">
{% if results.media.poster != null %} {% if results.media.poster != null %}
<img class="w-full md:w-40 rounded-lg" src="{{ results.media.poster }}" /> <img class="w-40 rounded-lg" src="{{ results.media.poster }}" />
{% else %} {% else %}
<div class="w-full md:w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center"> <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" /> <twig:ux:icon width="24" name="hugeicons:loading-01" />
</div> </div>
{% endif %} {% endif %}
@@ -22,30 +22,87 @@
{{ results.media.title }} - {{ results.media.year }} {{ results.media.title }} - {{ results.media.year }}
</h3> </h3>
{% if results.media.mediaType == "tvshows" %} {# <div data-controller="dropdown" class="relative"#}
{# {{ stimulus_controller('monitor_button', {#}
{# tmdbId: results.media.tmdbId,#}
{# imdbId: results.media.imdbId,#}
{# title: results.media.title,#}
{# })}}#}
{# data-monitor-button-result-filter-outlet="#filter"#}
{# >#}
{# <button type="button" data-action="dropdown#toggle click@window->dropdown#hide"#}
{# 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">#}
{# 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>#}
{# </svg>#}
{# </button>#}
{# <div#}
{# data-dropdown-target="menu"#}
{# class="hidden transition transform origin-top-right absolute right-0#}
{# flex flex-col rounded-md shadow-sm w-44 bg-green-800 border-2 border-green-500 mt-1"#}
{# data-transition-enter-from="opacity-0 scale-95"#}
{# data-transition-enter-to="opacity-100 scale-100"#}
{# data-transition-leave-from="opacity-100 scale-100"#}
{# data-transition-leave-to="opacity-0 scale-95"#}
{# >#}
{# <a href="#"#}
{# data-action="dropdown#toggle"#}
{# class="backdrop-filter p-2 bg-opacity-100 hover:bg-green-950 rounded-t-md"#}
{# >#}
{# Entire Series#}
{# </a>#}
{# <a href="#"#}
{# data-action="dropdown#toggle"#}
{# class="backdrop-filter p-2 bg-opacity-100 hover:bg-green-950 rounded-b-md"#}
{# >#}
{# Season#}
{# </a>#}
{# </div>#}
{# </div>#}
<div {{ stimulus_controller('monitor_button', { <div {{ stimulus_controller('monitor_button', {
tmdbId: results.media.tmdbId, tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId, imdbId: results.media.imdbId,
title: results.media.title, title: results.media.title,
})}} })}}
data-monitor-button-result-filter-outlet="#filter" data-monitor-button-result-filter-outlet="#filter"
data-monitor-button-dialog-outlet=".monitor-modal"
> >
<twig:Modal <button data-monitor-button-target="button" {{ stimulus_action('monitor_button', 'toggle', 'click') }}
unique_class="monitor-modal" class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm
button_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
px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2 border-green-500"
border-green-500" type="button"
container_class="monitor-modal"
heading="'Hol Up!" button_text="Monitor" submit_action="{{ stimulus_action('monitor_button', 'monitorSeries', 'click') }}" show_cancel show_submit
> >
Monitoring a series will continuously search for new episodes and attempt to automatically download them. Your download preferences Monitor
will be used to choose the correct file. To stop monitoring for new episodes, delete the 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">
<br /><br /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
Would you like to add a new monitor for "{{ results.media.title }}"? </svg>
</twig:Modal> </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>
{% endif %}
</div> </div>
@@ -58,10 +115,7 @@
<twig:Filter results="{{ results }}" filter="{{ filter }}" /> <twig:Filter results="{{ results }}" filter="{{ filter }}" />
{% if "movies" == results.media.mediaType %} {% if "movies" == results.media.mediaType %}
<div class="results" <div class="results" {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}>
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon"
>
</div> </div>
{% elseif "tvshows" == results.media.mediaType %} {% elseif "tvshows" == results.media.mediaType %}
<twig:TvEpisodeList <twig:TvEpisodeList
@@ -73,20 +127,4 @@
</twig:Card> </twig:Card>
</div> </div>
</div> </div>
<style>
html,
body {
height: 100%;
}
@media (min-width: 640px) {
thead tr:not(:first-child) {
display: none;
}
}
td:not(:last-child) {
border-bottom: 0;
}
</style>
{% endblock %} {% endblock %}

View File

@@ -1,76 +1,66 @@
<table class="w-full max-w-[75vw] text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 flex-row flex-no-wrap {{ results.media.mediaType == "tvshows" ? "hidden" : "options-table" }}" <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 {{ results.media.mediaType == "tvshows" ? "hidden" }}"
{{ stimulus_target(controller, "list") }} {{ stimulus_target(controller, "list") }}
> >
<thead class="text-xs text-gray-700 uppercase dark:text-gray-400"> <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
{% for result in results.results %} <tr class="dark:bg-stone-600 overflow-hidden">
<tr class="dark:bg-stone-600 overflow-hidden flex flex-col md:flex-col flex-no wrap md:table-row border-b border-gray-500">
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Size Size
</th> </th>
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Quality
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
Resolution Resolution
</th> </th>
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Codec Codec
</th> </th>
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Seeders Seeders
</th> </th>
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Provider Provider
</th> </th>
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Language Language
</th> </th>
<th scope="col" <th scope="col"
class="px-4 py-4 leading-[32px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Actions
</th> </th>
</tr> </tr>
{% endfor %}
</thead> </thead>
<tbody class="flex-1 sm:flex-none"> <tbody>
{% for result in results.results %} {% for result in results.results %}
<tr class="bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500" data-provider="{{ result.provider }}" data-quality="{{ result.quality }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}> <tr class="bg-white border-b dark:bg-slate-700 dark:border-gray-600 border-gray-200" data-provider="{{ result.provider }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}>
<td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <td id="size" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.size }} {{ result.size }}
</td> </td>
<td id="quality" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <td id="resolution" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.quality }}
</td>
<td id="resolution" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.resolution }} {{ result.resolution }}
</td> </td>
<td id="codec" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <td id="codec" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.codec }} {{ result.codec }}
</td> </td>
<td id="seeders" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <td id="seeders" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.seeders }} {{ result.seeders }}
</td> </td>
<td id="provider" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50 " data-provider="{{ result.provider }}"> <td id="provider" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50" data-provider="{{ result.provider }}">
{{ result.provider }} {{ result.provider }}
</td> </td>
<td id="language" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50 overflow-scroll" data-languages="{{ result.languages|json_encode }}"> <td id="language" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50" data-languages="{{ result.languages|json_encode }}">
{{ result.languageFlags|raw }} {{ result.languageFlags|raw }}
</td> </td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end"> <td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-end">
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50" <button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50"
{{ stimulus_controller('download_button', { {{ stimulus_controller('download_button', {
url: result.url, url: result.url,
title: results.media.title, title: results.media.title,
filename: result.filename, filename: result.filename,
mediaType: results.media.mediaType, mediaType: results.media.mediaType,
imdbId: results.media.imdbId ?? app.current_route_parameters.imdbId, imdbId: results.media.imdbId,
episodeId: results|episode_id_from_results episodeId: results|episode_id_from_results
}) }} }) }}
{{ stimulus_action('download_button', 'download', 'click') }} {{ stimulus_action('download_button', 'download', 'click') }}

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