Compare commits

...

30 Commits

Author SHA1 Message Date
c1a6cddb8f fix: action button size 2025-07-07 16:35:18 -05:00
64d3fbbddb fix: forces results card to full screen width 2025-07-07 16:15:19 -05:00
32389cb27a fix: adds action button to manually run monitors 2025-07-07 16:12:21 -05:00
5e48fdb978 fix: removes monitor button for movies 2025-07-07 15:06:26 -05:00
5f54e48b3f fix: adds modal for adding new monitor 2025-07-07 15:04:20 -05:00
073a37c080 fix: monitor logging 2025-07-07 14:08:42 -05:00
3fe28c74a1 fix: episode air date showing 1 day behind 2025-07-07 12:40:29 -05:00
5c5fa8fde2 fix: displays warning if reald debrid or tmdb keys are missing 2025-07-07 00:14:22 -05:00
8fa06d4462 chore: moves search controller to search module 2025-07-06 22:56:47 -05:00
1fc5a8e500 chore: moves common code to Base namespace 2025-07-06 22:53:13 -05:00
a0050e425b feat: adds quality profile 2025-07-06 19:49:26 -05:00
791af9c9e7 fix: works with tv & movies 2025-07-06 19:26:33 -05:00
e54bcd44d8 wip: filters movie results, adds options to filter input 2025-07-06 15:37:29 -05:00
402d513147 fix(styles): turns h1 into link to dashboard, removes console.logs 2025-07-06 13:22:23 -05:00
d2de374f57 fix(nav): adds margin to h1 heading on mobile so its not behind search bar 2025-07-06 13:03:51 -05:00
9a1847a2c3 fix: allows normal search alongside autocomplete 2025-07-06 12:41:56 -05:00
17f6316353 fix: better styles for active option 2025-07-06 12:19:37 -05:00
cc366eb09f fix: moves tmdb search under tmdb namespace 2025-07-06 11:07:11 -05:00
b0425f7085 fix: styles results, updates loader 2025-07-06 10:05:11 -05:00
023b1b7844 fix: redirects user on selection 2025-07-06 09:31:47 -05:00
eafcf3fcb1 wip: renders live search results 2025-07-06 09:07:51 -05:00
25f803d1dd fix: styles 2025-07-05 14:43:39 -05:00
98041fd20b fix: result filter not filtering 2025-07-05 13:58:00 -05:00
d29b84ec78 fix: better logging for monitor cleanup 2025-07-04 20:54:49 -05:00
ccce0303c3 fix: better logging for monitor cleanup 2025-07-04 15:53:13 -05:00
9eaa120257 fix: stuck monitors 2025-07-04 15:15:09 -05:00
d6cbb53da6 feat: adds torrentio api endpoint 2025-07-04 14:57:39 -05:00
bd47107399 fix: uses parent imdb id if episode id doesn't exist 2025-07-02 16:10:53 -05:00
ac97fdd08f fix: adds r-tablecell class 2025-07-01 23:03:15 -05:00
727c11e1c6 fix: makes user preferences page responsive 2025-06-30 09:16:33 -05:00
81 changed files with 995 additions and 285 deletions

View File

@@ -1,5 +1,16 @@
{ {
"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

@@ -0,0 +1,46 @@
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

@@ -26,12 +26,7 @@ export default class extends Controller {
// this.fooTarget.addEventListener('click', this._fooBar) // this.fooTarget.addEventListener('click', this._fooBar)
} }
navbarOutletConnected(outlet) {
console.log(outlet)
}
toggleMenu() { toggleMenu() {
console.log(this.navbarOutlet);
this.navbarOutlet.toggle(); this.navbarOutlet.toggle();
} }

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'] static outlets = ['result-filter', 'dialog']
static values = { static values = {
tmdbId: String, tmdbId: String,
imdbId: String, imdbId: String,
@@ -54,6 +54,9 @@ 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,6 +16,7 @@ export default class extends Controller {
}; };
static targets = ['list'] static targets = ['list']
static outlets = ['loading-icon']
options = [] options = []
optionsLoaded = false optionsLoaded = false
@@ -33,6 +34,8 @@ 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();
}); });
} }
} }
@@ -52,10 +55,12 @@ 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;
@@ -81,6 +86,7 @@ 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,8 +10,7 @@ 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() {
console.log(window.location.pathname); this.element.querySelectorAll('.nav-list a:not(.nav-foot)').forEach(link => {
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;

View File

@@ -11,6 +11,7 @@ export default class extends Controller {
languages = [] languages = []
providers = [] providers = []
qualities = []
seasons = [] seasons = []
activeFilter = { activeFilter = {
@@ -18,13 +19,15 @@ 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', 'selectAll', 'downloadSelected'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'quality', 'selectAll', 'downloadSelected']
static values = { static values = {
'media-type': String, 'media-type': String,
'episodes': Array, 'episodes': Array,
'reverseMappedQualities': Object,
} }
async connect() { async connect() {
@@ -34,21 +37,12 @@ export default class extends Controller {
await this.filter(); await this.filter();
} }
async movieResultsOutletConnected(outlet) { // Event is fired from movies/tvshows controllers to populate this data
await this.parseDownloadOptionForFilter(outlet) async loadOptions({detail: { options }}) {
} 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();
} }
@@ -105,6 +99,32 @@ 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'];
@@ -114,6 +134,7 @@ 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) {

View File

@@ -0,0 +1,59 @@
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,6 +51,7 @@ 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}`)
@@ -135,6 +136,7 @@ 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;
@@ -160,6 +162,7 @@ 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

@@ -23,7 +23,7 @@
@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/70 hover:bg-yellow-600 border-yellow-400 text-black @apply bg-yellow-500 hover:bg-yellow-600 border-yellow-400 text-black
} }
} }
@@ -64,6 +64,24 @@ 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 { .options-table {
display: flex; display: flex;
@@ -77,3 +95,30 @@ dialog[data-dialog-target="dialog"][closing] {
display: inline-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 media_cache -vv --time-limit=3600 command: php /app/bin/console messenger:consume async -vv --time-limit=3600
scheduler: scheduler:

View File

@@ -44,6 +44,7 @@
"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": "67e697578f7237f60726c0d93bfed001", "content-hash": "248d1e534ec6bb56594a7380fb2eb860",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -9071,6 +9071,96 @@
], ],
"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,4 +19,5 @@ 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

@@ -1,7 +1,15 @@
controllersIndex: controllersBase:
resource: resource:
path: ../src/Controller/ path: ../src/Base/Framework/Controller/
namespace: App\Controller namespace: App\Base\Framework\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' ]
@@ -29,3 +37,19 @@ controllersMonitor:
type: attribute type: attribute
defaults: defaults:
schemes: ['https'] schemes: ['https']
controllersTorrentio:
resource:
path: ../src/Torrentio/Framework/Controller
namespace: App\Torrentio\Framework\Controller
type: attribute
defaults:
schemes: ['https']
controllersTmdb:
resource:
path: ../src/Tmdb/Framework/Controller
namespace: App\Tmdb\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

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

View File

@@ -4,6 +4,15 @@
# 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

@@ -47,4 +47,21 @@ return [
'version' => '4.1.1', 'version' => '4.1.1',
'type' => 'css', '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

@@ -0,0 +1,49 @@
<?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,6 +1,6 @@
<?php <?php
namespace App\Enum; namespace App\Base\Enum;
enum MediaType: string enum MediaType: string
{ {

View File

@@ -1,12 +1,11 @@
<?php <?php
namespace App\Command; namespace App\Base\Framework\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,9 +1,11 @@
<?php <?php
namespace App\Command; namespace App\Base\Framework\Command;
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;
@@ -18,14 +20,17 @@ 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
@@ -34,6 +39,7 @@ 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;
} }
@@ -60,6 +66,26 @@ class SeedDatabaseCommand extends Command
$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 [
@@ -91,6 +117,13 @@ 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,13 +1,11 @@
<?php <?php
namespace App\Command; namespace App\Base\Framework\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\Controller; namespace App\Base\Framework\Controller;
use App\Util\Broadcaster; use App\Base\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,11 +1,10 @@
<?php <?php
namespace App\Controller; namespace App\Base\Framework\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;
@@ -21,6 +20,8 @@ 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(),
@@ -28,11 +29,4 @@ 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\Util; namespace App\Base\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\Util; namespace App\Base\Util;
class CountryCodes class CountryCodes
{ {

View File

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

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace App\Util; namespace App\Base\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\Util; namespace App\Base\Util;
class ProviderList class ProviderList
{ {

View File

@@ -0,0 +1,115 @@
<?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

@@ -2,6 +2,7 @@
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\PauseDownloadHandler; use App\Download\Action\Handler\PauseDownloadHandler;
use App\Download\Action\Handler\ResumeDownloadHandler; use App\Download\Action\Handler\ResumeDownloadHandler;
@@ -10,7 +11,6 @@ use App\Download\Action\Input\DownloadMediaInput;
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\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;

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

@@ -4,13 +4,13 @@ namespace App\Monitor\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
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\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;
@@ -35,59 +35,65 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler'); try {
$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::now()) { if (Carbon::createFromTimestamp($episodeData->episodeAirDate) > Carbon::today('UTC')) {
$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()
)
); );
}
$monitor->setStatus('In Progress'); $this->logger->info('> [MonitorTvEpisodeHandler] ...Found ' . count($results->results) . ' total download options, beginning evaluation');
$this->monitorRepository->getEntityManager()->flush();
$this->logger->info('> [MonitorTvEpisodeHandler] Searching for "' . $monitor->getTitle() . '" season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' download options'); $result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
$results = $this->getTvShowOptionsHandler->handle(
new GetTvShowOptionsCommand(
$monitor->getTmdbId(),
$monitor->getImdbId(),
$monitor->getSeason(),
$monitor->getEpisode()
)
);
$this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options'); if (null !== $result) {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"', ['filter' => UserPreferencesFactory::createFromUser($monitor->getUser())]);
$this->bus->dispatch(new DownloadMediaCommand(
$result->url,
$monitor->getTitle(),
$result->filename,
'tvshows',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());
} else {
$this->logger->info('> [MonitorTvEpisodeHandler] ...Found 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Active');
}
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
if (null !== $result) { $this->entityManager->flush();
$this->logger->info('> [MonitorTvEpisodeHandler] 1 matching result found: dispatching DownloadMediaCommand for "' . $result->title . '"'); } catch (\Throwable $exception) {
$this->bus->dispatch(new DownloadMediaCommand( $this->logger->error('> [MonitorTvEpisodeHandler] ...Exception thrown: ' . $exception->getMessage());
$result->url, $this->logger->error($exception->getMessage());
$monitor->getTitle(),
$result->filename,
'tvshows',
$monitor->getImdbId(),
$monitor->getUser()->getId(),
));
$monitor->setStatus('Complete');
$monitor->setDownloadedAt(new DateTimeImmutable());
} else {
$this->logger->info('> [MonitorTvEpisodeHandler] 0 matching results found, monitor will run at next interval');
$monitor->setStatus('Active'); $monitor->setStatus('Active');
$this->monitorRepository->getEntityManager()->flush();
} }
$monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->entityManager->flush();
return new MonitorTvEpisodeResult( return new MonitorTvEpisodeResult(
status: 'OK', status: 'OK',
result: [ result: [

View File

@@ -61,26 +61,27 @@ 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] Episode released after monitor started for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeInFuture ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] ...Released after monitor started? ' . (true === $episodeInFuture ? 'YES' : 'NO'));
if (false === $episodeInFuture) { if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping'); $this->logger->info('> [MonitorTvShowHandler] ...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 for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] ...Episode exists? ' . (true === $episodeExists ? 'YES' : 'NO'));
if (true === $episodeExists) { if (true === $episodeExists) {
$this->logger->info('> [MonitorTvShowHandler] Episode exists for title: ' . $monitor->getTitle() . ', skipping'); $this->logger->info('> [MonitorTvShowHandler] ...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 for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $monitorExists ? 'YES' : 'NO')); $this->logger->info('> [MonitorTvShowHandler] ...Monitor exists? ' . (true === $monitorExists ? 'YES' : 'NO'));
if (true === $monitorExists) { if (true === $monitorExists) {
$this->logger->info('> [MonitorTvShowHandler] Monitor exists for title: ' . $monitor->getTitle() . ', skipping'); $this->logger->info('> [MonitorTvShowHandler] ...Skipping');
continue; continue;
} }
@@ -106,7 +107,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 for season ' . $episodeMonitor->getSeason() . ' episode ' . $episodeMonitor->getEpisode() . ' for title: ' . $monitor->getTitle()); $this->logger->info('> [MonitorTvShowHandler] ...Dispatching MonitorTvEpisodeCommand');
} }
} }

View File

@@ -2,12 +2,14 @@
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\Util\Broadcaster; use App\Monitor\Framework\Scheduler\MonitorDispatcher;
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;
@@ -50,4 +52,17 @@ 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,6 +4,7 @@ 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;
@@ -148,7 +149,7 @@ class Monitor
public function getLastSearch(): ?\DateTimeInterface public function getLastSearch(): ?\DateTimeInterface
{ {
return $this->lastSearch; return Carbon::parse($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,6 +7,7 @@ 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;
@@ -23,6 +24,8 @@ 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,
@@ -41,4 +44,18 @@ 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,8 +2,6 @@
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,6 +1,6 @@
<?php <?php
namespace App\Controller; namespace App\Search\Framework\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 SearchController extends AbstractController final class WebController extends AbstractController
{ {
public function __construct( public function __construct(
private SearchHandler $searchHandler, private SearchHandler $searchHandler,

View File

@@ -0,0 +1,38 @@
<?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,14 +3,11 @@
namespace App\Tmdb; namespace App\Tmdb;
use Aimeos\Map; use Aimeos\Map;
use App\Enum\MediaType; use App\Base\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;
@@ -20,7 +17,6 @@ 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;

View File

@@ -32,7 +32,7 @@ class Torrentio
]); ]);
} }
public function search(string $imdbCode, string $type, array $filter = []): array public function search(string $imdbCode, string $type, bool $parseResults = true): array
{ {
$cacheKey = "torrentio.{$imdbCode}"; $cacheKey = "torrentio.{$imdbCode}";
@@ -56,10 +56,14 @@ class Torrentio
return []; return [];
}); });
return $this->parse($results, $filter); if (true === $parseResults) {
return $this->parse($results);
}
return $results;
} }
public function fetchEpisodeResults(string $imdbId, int $season, int $episode): array public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): 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->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
@@ -86,18 +90,15 @@ class Torrentio
throw new TorrentioRateLimitException(); throw new TorrentioRateLimitException();
} }
return $this->parse($results, []); if (true === $parseResults) {
} return $this->parse($results);
public function parse(array $data, array $filter): array
{
$ruleEngine = new RuleEngine();
foreach ($filter as $rule => $value) {
if ('resolution' === $rule) {
$ruleEngine->addRule(new Resolution($value));
}
} }
return $results;
}
public function parse(array $data): array
{
$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")) {
@@ -119,9 +120,7 @@ class Torrentio
$bingeGroup $bingeGroup
); );
if ($ruleEngine->validateAll($result)) { $results[] = $result;
$results[] = $result;
}
} }
return $results; return $results;

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Client\Torrentio;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ApiController extends AbstractController
{
public function __construct(
private readonly Torrentio $torrentio,
) {}
#[Route('/api/torrentio/{imdbId}/{season?}/{episode?}', name: 'api_torrentio')]
public function api(string $imdbId, ?int $season, ?int $episode): Response
{
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

@@ -1,13 +1,13 @@
<?php <?php
namespace App\Controller; namespace App\Torrentio\Framework\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;
@@ -16,7 +16,7 @@ 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;
final class TorrentioController extends AbstractController final class WebController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler, private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
@@ -54,13 +54,13 @@ final class TorrentioController extends AbstractController
); );
try { try {
return $cache->get($cacheId, function (ItemInterface $item) use ($input) { // return $cache->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));
$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

@@ -2,8 +2,7 @@
namespace App\Torrentio\Result; namespace App\Torrentio\Result;
use App\Util\CountryCodes; use App\Base\Util\CountryLanguages;
use App\Util\CountryLanguages;
use Nihilarr\PTN; use Nihilarr\PTN;
class ResultFactory class ResultFactory
@@ -22,6 +21,7 @@ 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,6 +34,7 @@ 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,6 +16,7 @@ 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

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

View File

@@ -3,11 +3,8 @@
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,6 +3,7 @@
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;
@@ -17,6 +18,8 @@ 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,
@@ -27,5 +30,6 @@ 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\Util\Paginator; use App\Base\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,6 +10,7 @@ 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,6 +18,9 @@ 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,
@@ -33,6 +36,7 @@ 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

@@ -0,0 +1,15 @@
<?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

@@ -0,0 +1,19 @@
<?php
namespace App\User\Dto;
use App\User\Framework\Entity\User;
class UserPreferencesFactory
{
public static function createFromUser(User $user): UserPreferences
{
return new UserPreferences(
resolution: $user->getUserPreference('resolution')->getPreferenceValue(),
codec: $user->getUserPreference('codec')->getPreferenceValue(),
language: $user->getUserPreference('language')->getPreferenceValue(),
provider: $user->getUserPreference('provider')->getPreferenceValue(),
quality: $user->getUserPreference('quality')->getPreferenceValue(),
);
}
}

View File

@@ -4,14 +4,15 @@ 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;
@@ -36,7 +37,8 @@ class PreferencesController extends AbstractController
[ [
'preferences' => $this->preferencesRepository->findEnabled(), 'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages, 'languages' => $languages,
'providers' => ProviderList::$providers, 'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
] ]
@@ -67,6 +69,7 @@ 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,
] ]
@@ -95,7 +98,8 @@ class PreferencesController extends AbstractController
[ [
'preferences' => $this->preferencesRepository->findEnabled(), 'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages, 'languages' => $languages,
'providers' => ProviderList::$providers, 'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences, 'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
] ]

View File

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

View File

@@ -2,6 +2,8 @@
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;
@@ -10,12 +12,26 @@ 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')] #[AsEventListener(event: 'security.authentication.success', priority: 20)]
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

@@ -281,6 +281,18 @@
"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,6 +5,11 @@ 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",
@@ -21,7 +26,13 @@ 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

@@ -20,7 +20,12 @@
</div> </div>
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll"> <div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<twig:Header /> <twig:Header />
<h2 class="px-4 my-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2> <div class="flex justify-between items-center">
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex mt-4 gap-2 items-center grow-0 md:px-4">
{% block action_buttons %}{% endblock %}
</div>
</div>
{% block body %}{% endblock %} {% block body %}{% endblock %}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,7 @@
<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 text-sm font-semibold"
{{ attributes.defaults(stimulus_controller('action_button')) }}
{{ stimulus_action('action_button', action|default('default')) }}
>
{{ text|default('button') }}
</button>

View File

@@ -7,20 +7,9 @@
<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">{{ title|default('') }}</h3> <h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
</div> </div>
<div class="mt-2 text-sm"> <div class="mt-2 text-sm w-[350px] font-bold">
{{ 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 backdrop-filter backdrop-blur-md bg-opacity-40 z-10
"> ">
<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

@@ -15,11 +15,11 @@
Title Title
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ?? "hidden" }}"> 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" }}">
Filename Filename
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 truncate {{ isWidget == true ?? "hidden" }}"> 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" }}">
Media type Media type
</th> </th>
<th scope="col" <th scope="col"
@@ -27,7 +27,7 @@
Progress Progress
</th> </th>
<th scope="col" <th scope="col"
class="hidden md:table-cell 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">
</th> </th>
</tr> </tr>
</thead> </thead>

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" class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate"
> >
{{ download.title }} {{ download.title }}
</a> </a>
@@ -11,11 +11,11 @@
{% endif %} {% endif %}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ?? "hidden" }} truncate"> <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">
{{ download.filename }} {{ download.filename }}
</td> </td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate {{ isWidget == true ?? "hidden" }}"> <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" }}">
{{ download.mediaType }} {{ download.mediaType }}
</td> </td>

View File

@@ -1,10 +1,10 @@
<div id="filter" class="flex flex-col gap-4" <div id="filter" class="flex flex-col gap-4"
{{ stimulus_controller('result_filter') }} {{ stimulus_controller('result_filter', {reverseMappedQualities: this.reverseMappedQualities}) }}
{{ 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"
> >
<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-col md:flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution"> <label for="resolution">
@@ -55,6 +55,17 @@
> >
</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

View File

@@ -1,6 +1,7 @@
<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">
@@ -27,7 +28,10 @@
</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"> <ul id="alert_list" class="flex flex-col gap-2">
{% 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="flex flex-row items-center"> <div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose" class="modal flex flex-row items-center {{ container_class|default('') }}">
<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>
@@ -22,5 +22,5 @@
{% endif %} {% endif %}
</dialog> </dialog>
<button type="button" data-action="dialog#open">{{ button_text|raw }}</button> <button type="button" class="{{ button_class|default('') }}" data-action="dialog#open">{{ button_text|raw }}</button>
</div> </div>

View File

@@ -41,7 +41,7 @@
<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" /> <twig:MonitorListRow :monitor="monitor" isWidget="{{ this.isWidget }}" />
{% 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

@@ -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 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">
<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 font-extrabold text-orange-500 mb-3">Torsearch</h1> <h1 class="text-3xl mt-12 md:mt-0 font-extrabold text-orange-500 mb-3"><a href="{{ path('app_index') }}">Torsearch</a></h1>
<ul class="space-y-1"> <ul class="nav-list 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

@@ -1,15 +1,24 @@
<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 action="{{ path('app_search') }}"> <form id="search" action="{{ path('app_search') }}">
<input <select
{{ 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-2 transition text-sm border border-orange-500 rounded-md pl-3 pr-28 py-0 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" shadow-sm focus:shadow ts-search z-40"
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
@@ -18,7 +27,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 backdrop-filter backdrop-blur-md bg-opacity-80 z-40
" "
type="submit" type="submit"
> >

View File

@@ -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 }} {{ episode['air_date']|date(null, 'UTC') }}
</small> </small>
</div> </div>
</div> </div>

View File

@@ -4,13 +4,13 @@
{% block h2 %}Downloads{% endblock %} {% block h2 %}Downloads{% endblock %}
{% block body %} {% block body %}
<div class="p-4"> <div class="px-4 py-2">
<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

@@ -1,10 +1,10 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block title %}Dashboard &mdash; Torsearch{% endblock %} {% block title %}Dashboard &mdash; Torsearch{% endblock %}
{% block h2 %}Dashboard{% endblock %}
{% block body %} {% block body %}
<div class="p-4 flex flex-col grow gap-4 z-30"> <div class="p-4 flex flex-col grow gap-4 z-10">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md: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'" />

View File

@@ -3,17 +3,19 @@
{% 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="flex flex-row"> <div class="px-4 py-2">
<twig:Card title="Active Monitors">
<div class="p-2 flex flex-col gap-4"> <twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList>
<twig:Card title="Active Monitors"> </twig:Card>
<twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList> </div>
</twig:Card> <div class="px-4 py-2">
<twig:Card title="Complete Monitors"> <twig:Card title="Complete Monitors">
<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> </div>
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,7 @@
<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="" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50"> <twig:Card title="" class="w-full" 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-2 md:p-4 flex flex-col md: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-full md:w-40 rounded-lg" src="{{ results.media.poster }}" />
@@ -22,87 +22,30 @@
{{ results.media.title }} - {{ results.media.year }} {{ results.media.title }} - {{ results.media.year }}
</h3> </h3>
{# <div data-controller="dropdown" class="relative"#} {% if results.media.mediaType == "tvshows" %}
{# {{ 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"
> >
<button data-monitor-button-target="button" {{ stimulus_action('monitor_button', 'toggle', 'click') }} <twig:Modal
class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm unique_class="monitor-modal"
px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2 button_class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm
border-green-500" px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2
type="button" border-green-500"
container_class="monitor-modal"
heading="'Hol Up!" button_text="Monitor" submit_action="{{ stimulus_action('monitor_button', 'monitorSeries', 'click') }}" show_cancel show_submit
> >
Monitor Monitoring a series will continuously search for new episodes and attempt to automatically download them. Your download preferences
<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"> will be used to choose the correct file. To stop monitoring for new episodes, delete the monitor.
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/> <br /><br />
</svg> Would you like to add a new monitor for "{{ results.media.title }}"?
</button> </twig:Modal>
<!-- 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>
@@ -115,7 +58,10 @@
<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" {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}> <div class="results"
{{ 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

View File

@@ -8,6 +8,10 @@
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white"> class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
Size Size
</th> </th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
Quality
</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-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
Resolution Resolution
@@ -37,10 +41,13 @@
</thead> </thead>
<tbody class="flex-1 sm:flex-none"> <tbody class="flex-1 sm:flex-none">
{% for result in results.results %} {% for result in results.results %}
<tr class="bg-white dark:bg-slate-700 flex flex-col flex-no wrap sm:table-row border-b border-gray-500" data-provider="{{ result.provider }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}> <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 %}>
<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-4 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">
{{ result.quality }}
</td>
<td id="resolution" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50"> <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>
@@ -63,7 +70,7 @@
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, imdbId: results.media.imdbId ?? app.current_route_parameters.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') }}

View File

@@ -3,10 +3,24 @@
{% block h2 %}Preferences{% endblock %} {% block h2 %}Preferences{% endblock %}
{% block body %} {% block body %}
<div class="p-4 flex flex-row gap-2"> <div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Media Preferences" class="w-full"> <twig:Card title="Media Preferences" class="w-full">
<p class="text-gray-50 mb-2">Define a filter to be pre-applied to your download options.</p> <p class="text-gray-50 mb-2">Define a filter to be pre-applied to your download options.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_save_media_preferences') }}"> <form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_save_media_preferences') }}">
<label class="text-gray-50" for="quality">Quality</label>
<select class="p-1.5 rounded-md mb-2" name="quality" id="quality" value="{{ mediaPreferences['quality'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['quality'].getPreferenceValue() is null ? "selected" }}
>n/a</option>
{% for quality in qualities %}
<option class="text-gray-800"
value="{{ quality }}"
{{ quality == mediaPreferences['quality'].getPreferenceValue() ? "selected" }}
>{{ quality }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="resolution">Resolution</label> <label class="text-gray-50" for="resolution">Resolution</label>
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ mediaPreferences['resolution'].getPreferenceValue() }}"> <select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ mediaPreferences['resolution'].getPreferenceValue() }}">
<option class="text-gray-800" <option class="text-gray-800"