Compare commits

...

48 Commits

Author SHA1 Message Date
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
be65e2d4e2 fix: makes download list & monitor list responsive 2025-06-30 09:13:49 -05:00
497a3a74cd fix: basic hamburger menu button 2025-06-29 23:19:16 -05:00
101460cd47 fix: mobile results formatting 2025-06-29 22:26:01 -05:00
591c9cdd2a wip: mobile template 2025-06-29 16:01:56 -05:00
2dc53c5270 feat: tags torrentio cache 2025-06-22 22:08:08 -05:00
5938d33c89 fix: alert topic id not being set after getting started controller 2025-06-22 16:40:43 -05:00
d95ab85415 fix: udpates example compose 2025-06-22 16:21:04 -05:00
cc39e46bfd fix: broken delete monitor 2025-06-22 15:50:09 -05:00
ba092ab3c2 chore: remove line 2025-06-22 15:12:37 -05:00
774d6f4999 fix: caches tmdb data 2025-06-22 14:06:01 -05:00
0b56ee937d fix: removes upcoming episodes component from monitors page 2025-06-22 10:08:23 -05:00
7b3d57b94a fix: prepends episode/season number to filename if doesn't exist. should fix monitors repeatedly downloading episodes 2025-06-21 23:26:55 -05:00
be7b610111 fix: monitor tv -> set monitor create date time to 00:00.00 for comparison to air date 2025-06-20 11:46:11 -05:00
3e93a7c9c1 fix: un-hardcodes version 2025-06-20 11:29:50 -05:00
bc78b83f8d fix: includes version number in worker & scheduler 2025-06-20 11:06:34 -05:00
c5bcaeb1d4 fix: shows version in nav bar 2025-06-20 11:05:44 -05:00
e39182ba91 fix: defaults episode count to '-' as an indicator to whether the fetch call has run or not. if there are 0 results after the fetch call, the '-' is updated to '0' 2025-06-20 08:37:35 -05:00
965b747594 fix: monitor checks if episode was released after monitor created 2025-06-20 08:11:27 -05:00
937e3c6270 fix: updates monitor to search for episodes that were released after monitor created 2025-06-20 07:34:18 -05:00
2bb2845ead fix: torrentio for movies 2025-06-19 23:32:07 -05:00
fca189648b fix: adds warning for torrentio rate limit 2025-06-19 23:08:08 -05:00
2121466322 wip: gracefully handles torrentio 429 2025-06-19 22:39:41 -05:00
1e130c3490 fix: stores sessions in redis 2025-06-19 19:50:02 -05:00
4b97faeadb fix: error querying tmdb 2025-06-19 19:30:28 -05:00
3701e31ee0 fix: sets default twig date format as m/d/Y 2025-06-19 16:44:20 -05:00
210c674f25 fix: bad date format 2025-06-19 16:41:42 -05:00
175f4330f1 fix: returns episode data on first page load 2025-06-19 16:23:39 -05:00
12aaf8e737 fix: animates episode toggle list button 2025-06-19 14:49:58 -05:00
2e468dd9b0 fix: cleans up paginator 2025-06-19 14:30:26 -05:00
e070b95a36 wip: working episode pagination, season switcher, monitor only new content 2025-06-19 13:30:22 -05:00
20d397589a fix: undoes num_threads 2025-06-14 15:14:53 -05:00
78 changed files with 2074 additions and 343 deletions

View File

@@ -3,6 +3,7 @@ FROM dunglas/frankenphp:php8.4
ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ENV APP_VERSION="0.0.1"
RUN install-php-extensions \
pdo_mysql \

View File

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

View File

@@ -12,6 +12,7 @@ export default class extends Controller {
filename: String,
mediaType: String,
imdbId: String,
episodeId: String
}
download() {
@@ -27,6 +28,7 @@ export default class extends Controller {
filename: this.filenameValue,
mediaType: this.mediaTypeValue,
imdbId: this.imdbIdValue,
episodeId: this.episodeIdValue
})
})
.then(res => res.json())

View File

@@ -0,0 +1,43 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static outlets = ['navbar']
initialize() {
// Called once when the controller is first instantiated (per element)
// Here you can initialize variables, create scoped callables for event
// listeners, instantiate external libraries, etc.
// this._fooBar = this.fooBar.bind(this)
}
connect() {
// Called every time the controller is connected to the DOM
// (on page load, when it's added to the DOM, moved in the DOM, etc.)
// Here you can add event listeners on the element or target elements,
// add or remove classes, attributes, dispatch custom events, etc.
// this.fooTarget.addEventListener('click', this._fooBar)
}
toggleMenu() {
this.navbarOutlet.toggle();
}
// Add custom controller actions here
// fooBar() { this.fooTarget.classList.toggle(this.bazClass) }
disconnect() {
// Called anytime its element is disconnected from the DOM
// (on page change, when it's removed from or moved in the DOM, etc.)
// Here you should remove all event listeners added in "connect()"
// this.fooTarget.removeEventListener('click', this._fooBar)
}
}

View File

@@ -56,6 +56,7 @@ export default class extends Controller {
}
let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
@@ -81,6 +82,7 @@ export default class extends Controller {
}
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;

View File

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

View File

@@ -20,7 +20,7 @@ export default class extends Controller {
"provider": "",
}
static outlets = ['movie-results', 'tv-results']
static outlets = ['movie-results', 'tv-results', 'tv-episode-list']
static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected']
static values = {
'media-type': String,
@@ -127,6 +127,10 @@ export default class extends Controller {
}
}
setSeason(event) {
this.tvEpisodeListOutlet.setSeason(event.target.value);
}
uncheckSelectAllBtn() {
this.selectAllTarget.checked = false;
}

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

@@ -0,0 +1,51 @@
import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
/*
* 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 {
async initialize() {
this.component = await getComponent(this.element);
this.component.on('render:finished', (component) => {
console.log(component);
});
}
setSeason(season) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('pageNumber', 1);
this.component.set('season', parseInt(season));
this.component.render();
}
paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.action('paginate', {page: event.params.page});
this.component.render();
}
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)
}
}

View File

@@ -18,32 +18,43 @@ export default class extends Controller {
active: Boolean,
};
static targets = ['list', 'count', 'episodeSelector']
static targets = ['list', 'count', 'episodeSelector', 'toggleButton', 'listContainer']
static outlets = ['loading-icon']
options = []
optionsLoaded = false
isOpen = false
async connect() {
await this.setOptions();
}
async setOptions() {
if (true === this.activeValue && this.optionsLoaded === false) {
if (this.optionsLoaded === false) {
this.optionsLoaded = true;
await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
});
let response;
try {
response = await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
} catch (error) {
console.log('There was an error', error);
}
if (response?.ok) {
response = await response.text()
this.listContainerTarget.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
if (this.options.length > 0) {
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.options[0].querySelector('input[type="checkbox"]').checked = true;
} else {
this.countTarget.innerText = 0;
this.episodeSelectorTarget.disabled = true;
}
this.loadingIconOutlet.increaseCount();
} else {
console.log(`HTTP Response Code: ${response?.status}`)
}
}
}
@@ -55,19 +66,13 @@ export default class extends Controller {
// }
async setActive() {
this.activeValue = true;
this.element.classList.remove('hidden');
if (false === this.optionsLoaded) {
await this.setOptions();
}
}
async setInActive() {
this.activeValue = false;
// if (true === this.hasEpisodeSelectorTarget()) {
this.episodeSelectorTarget.checked = false;
// }
this.element.classList.add('hidden');
}
isActive() {
@@ -85,7 +90,10 @@ export default class extends Controller {
}
toggleList() {
this.listTarget.classList.toggle('options-table');
this.listTarget.classList.toggle('hidden');
this.toggleButtonTarget.classList.toggle('rotate-90');
this.toggleButtonTarget.classList.toggle('-rotate-90');
}
download() {
@@ -127,6 +135,7 @@ export default class extends Controller {
}
let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
@@ -152,6 +161,7 @@ export default class extends Controller {
}
if (false === include) {
option.classList.remove('r-tablerow');
option.classList.add('hidden');
} else if (true === firstIncluded) {
count = 1;

View File

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

After

Width:  |  Height:  |  Size: 333 B

View File

@@ -15,6 +15,18 @@
}
}
@layer components {
.alert {
@apply text-white text-sm min-w-[250px] border px-4 py-3 rounded-md
}
.alert-success {
@apply bg-green-950 hover:bg-green-900 border-green-500
}
.alert-warning {
@apply bg-yellow-500/70 hover:bg-yellow-600 border-yellow-400 text-black
}
}
/* Prevent scrolling while dialog is open */
body:has(dialog[data-dialog-target="dialog"][open]) {
overflow: hidden;
@@ -51,3 +63,62 @@ dialog[data-dialog-target="dialog"][open] {
dialog[data-dialog-target="dialog"][closing] {
animation: fade-out 200ms forwards;
}
.r-tablecell {
display: none;
}
.r-tablerow {
display: flex;
}
@media screen and (min-width: 768px) {
.r-tablecell {
display: inline-table;
}
.r-tablerow {
display: table-row;
}
}
.options-table {
display: flex;
:last-child {
border-bottom: none;
}
}
@media screen and (min-width: 768px) {
.options-table {
display: inline-table;
}
}
#search .ts-wrapper.single .ts-control::after {
display: none !important;
}
#search .ts-control {
background: transparent !important;
border: none !important;
box-shadow: none !important;
color: #fff !important;
padding-left: 0;
input {
color: #fff !important;
padding: 0;
}
}
#search .ts-dropdown {
background: unset;
@apply bg-orange-500/80 backdrop-filter backdrop-blur-md text-white border border-orange-500 rounded-md
}
#search .ts-dropdown .ts-dropdown-content .option.active {
background: unset;
@apply bg-orange-500/80 text-black font-bold rounded-md
}

View File

@@ -16,11 +16,13 @@
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"guzzlehttp/guzzle": "^7.9",
"league/pipeline": "^1.1",
"nesbot/carbon": "^3.9",
"nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1",
"predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0",
@@ -42,6 +44,7 @@
"symfony/security-bundle": "7.3.*",
"symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27",
"symfony/ux-icons": "^2.24",
"symfony/ux-live-component": "^2.24",
"symfony/ux-turbo": "^2.24",

829
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "e8b5e39f9d73a6ace2b9e39240186b4f",
"content-hash": "248d1e534ec6bb56594a7380fb2eb860",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -285,6 +285,72 @@
},
"time": "2021-10-17T22:52:23+00:00"
},
{
"name": "clue/stream-filter",
"version": "v1.7.0",
"source": {
"type": "git",
"url": "https://github.com/clue/stream-filter.git",
"reference": "049509fef80032cb3f051595029ab75b49a3c2f7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/clue/stream-filter/zipball/049509fef80032cb3f051595029ab75b49a3c2f7",
"reference": "049509fef80032cb3f051595029ab75b49a3c2f7",
"shasum": ""
},
"require": {
"php": ">=5.3"
},
"require-dev": {
"phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
},
"type": "library",
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"Clue\\StreamFilter\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christian Lück",
"email": "christian@clue.engineering"
}
],
"description": "A simple and modern approach to stream filtering in PHP",
"homepage": "https://github.com/clue/stream-filter",
"keywords": [
"bucket brigade",
"callback",
"filter",
"php_user_filter",
"stream",
"stream_filter_append",
"stream_filter_register"
],
"support": {
"issues": "https://github.com/clue/stream-filter/issues",
"source": "https://github.com/clue/stream-filter/tree/v1.7.0"
},
"funding": [
{
"url": "https://clue.engineering/support",
"type": "custom"
},
{
"url": "https://github.com/clue",
"type": "github"
}
],
"time": "2023-12-20T15:40:13+00:00"
},
{
"name": "composer/semver",
"version": "3.4.3",
@@ -1947,6 +2013,331 @@
],
"time": "2025-04-04T17:19:27+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.9.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
"reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
"reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^1.5.3 || ^2.0.3",
"guzzlehttp/psr7": "^2.7.0",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
},
"provide": {
"psr/http-client-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.39 || ^9.6.20",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
"ext-curl": "Required for CURL handler support",
"ext-intl": "Required for Internationalized Domain Name (IDN) support",
"psr/log": "Required for using the Log middleware"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"files": [
"src/functions_include.php"
],
"psr-4": {
"GuzzleHttp\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Jeremy Lindblom",
"email": "jeremeamia@gmail.com",
"homepage": "https://github.com/jeremeamia"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle is a PHP HTTP client library",
"keywords": [
"client",
"curl",
"framework",
"http",
"http client",
"psr-18",
"psr-7",
"rest",
"web service"
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
"source": "https://github.com/guzzle/guzzle/tree/7.9.3"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
"type": "tidelift"
}
],
"time": "2025-03-27T13:37:11+00:00"
},
{
"name": "guzzlehttp/promises",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
"reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
"reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
}
],
"description": "Guzzle promises library",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
"source": "https://github.com/guzzle/promises/tree/2.2.0"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
"type": "tidelift"
}
],
"time": "2025-03-27T13:27:01+00:00"
},
{
"name": "guzzlehttp/psr7",
"version": "2.7.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
"reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
"phpunit/phpunit": "^8.5.39 || ^9.6.20"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.7.1"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2025-03-27T12:30:47+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.5.0",
@@ -2540,6 +2931,130 @@
},
"time": "2024-02-19T18:29:05+00:00"
},
{
"name": "php-http/cache-plugin",
"version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-http/cache-plugin.git",
"reference": "5c591e9e04602cec12307e3e1be3abefeb005e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/cache-plugin/zipball/5c591e9e04602cec12307e3e1be3abefeb005e29",
"reference": "5c591e9e04602cec12307e3e1be3abefeb005e29",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"php-http/client-common": "^1.9 || ^2.0",
"psr/cache": "^1.0 || ^2.0 || ^3.0",
"psr/http-factory-implementation": "^1.0",
"symfony/options-resolver": "^2.6 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"require-dev": {
"nyholm/psr7": "^1.6.1",
"phpspec/phpspec": "^5.1 || ^6.0 || ^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Client\\Common\\Plugin\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "PSR-6 Cache plugin for HTTPlug",
"homepage": "http://httplug.io",
"keywords": [
"cache",
"http",
"httplug",
"plugin"
],
"support": {
"issues": "https://github.com/php-http/cache-plugin/issues",
"source": "https://github.com/php-http/cache-plugin/tree/2.0.1"
},
"time": "2024-10-02T11:25:38+00:00"
},
{
"name": "php-http/client-common",
"version": "2.7.2",
"source": {
"type": "git",
"url": "https://github.com/php-http/client-common.git",
"reference": "0cfe9858ab9d3b213041b947c881d5b19ceeca46"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/client-common/zipball/0cfe9858ab9d3b213041b947c881d5b19ceeca46",
"reference": "0cfe9858ab9d3b213041b947c881d5b19ceeca46",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"php-http/httplug": "^2.0",
"php-http/message": "^1.6",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0 || ^2.0",
"symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0 || ^7.0",
"symfony/polyfill-php80": "^1.17"
},
"require-dev": {
"doctrine/instantiator": "^1.1",
"guzzlehttp/psr7": "^1.4",
"nyholm/psr7": "^1.2",
"phpspec/phpspec": "^5.1 || ^6.3 || ^7.1",
"phpspec/prophecy": "^1.10.2",
"phpunit/phpunit": "^7.5.20 || ^8.5.33 || ^9.6.7"
},
"suggest": {
"ext-json": "To detect JSON responses with the ContentTypePlugin",
"ext-libxml": "To detect XML responses with the ContentTypePlugin",
"php-http/cache-plugin": "PSR-6 Cache plugin",
"php-http/logger-plugin": "PSR-3 Logger plugin",
"php-http/stopwatch-plugin": "Symfony Stopwatch plugin"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Client\\Common\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Common HTTP Client implementations and tools for HTTPlug",
"homepage": "http://httplug.io",
"keywords": [
"client",
"common",
"http",
"httplug"
],
"support": {
"issues": "https://github.com/php-http/client-common/issues",
"source": "https://github.com/php-http/client-common/tree/2.7.2"
},
"time": "2024-09-24T06:21:48+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
@@ -2619,6 +3134,184 @@
},
"time": "2024-10-02T11:20:13+00:00"
},
{
"name": "php-http/httplug",
"version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/php-http/httplug.git",
"reference": "5cad731844891a4c282f3f3e1b582c46839d22f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4",
"reference": "5cad731844891a4c282f3f3e1b582c46839d22f4",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0",
"php-http/promise": "^1.1",
"psr/http-client": "^1.0",
"psr/http-message": "^1.0 || ^2.0"
},
"require-dev": {
"friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0",
"phpspec/phpspec": "^5.1 || ^6.0 || ^7.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Eric GELOEN",
"email": "geloen.eric@gmail.com"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "HTTPlug, the HTTP client abstraction for PHP",
"homepage": "http://httplug.io",
"keywords": [
"client",
"http"
],
"support": {
"issues": "https://github.com/php-http/httplug/issues",
"source": "https://github.com/php-http/httplug/tree/2.4.1"
},
"time": "2024-09-23T11:39:58+00:00"
},
{
"name": "php-http/message",
"version": "1.16.2",
"source": {
"type": "git",
"url": "https://github.com/php-http/message.git",
"reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/message/zipball/06dd5e8562f84e641bf929bfe699ee0f5ce8080a",
"reference": "06dd5e8562f84e641bf929bfe699ee0f5ce8080a",
"shasum": ""
},
"require": {
"clue/stream-filter": "^1.5",
"php": "^7.2 || ^8.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.6",
"ext-zlib": "*",
"guzzlehttp/psr7": "^1.0 || ^2.0",
"laminas/laminas-diactoros": "^2.0 || ^3.0",
"php-http/message-factory": "^1.0.2",
"phpspec/phpspec": "^5.1 || ^6.3 || ^7.1",
"slim/slim": "^3.0"
},
"suggest": {
"ext-zlib": "Used with compressor/decompressor streams",
"guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories",
"laminas/laminas-diactoros": "Used with Diactoros Factories",
"slim/slim": "Used with Slim Framework PSR-7 implementation"
},
"type": "library",
"autoload": {
"files": [
"src/filters.php"
],
"psr-4": {
"Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "HTTP Message related tools",
"homepage": "http://php-http.org",
"keywords": [
"http",
"message",
"psr-7"
],
"support": {
"issues": "https://github.com/php-http/message/issues",
"source": "https://github.com/php-http/message/tree/1.16.2"
},
"time": "2024-10-02T11:34:13+00:00"
},
{
"name": "php-http/promise",
"version": "1.3.1",
"source": {
"type": "git",
"url": "https://github.com/php-http/promise.git",
"reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83",
"reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3",
"phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4"
},
"type": "library",
"autoload": {
"psr-4": {
"Http\\Promise\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Joel Wurtz",
"email": "joel.wurtz@gmail.com"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Promise used for asynchronous HTTP requests",
"homepage": "http://httplug.io",
"keywords": [
"promise"
],
"support": {
"issues": "https://github.com/php-http/promise/issues",
"source": "https://github.com/php-http/promise/tree/1.3.1"
},
"time": "2024-03-15T13:55:21+00:00"
},
{
"name": "php-tmdb/api",
"version": "4.1.3",
@@ -3527,6 +4220,50 @@
},
"time": "2021-10-29T13:26:27+00:00"
},
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{
"name": "runtime/frankenphp-symfony",
"version": "0.2.0",
@@ -8334,6 +9071,96 @@
],
"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",
"version": "v2.26.0",

View File

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

View File

@@ -11,7 +11,7 @@ framework:
trusted_headers: [ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix' ]
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
handler_id: '%env(REDIS_HOST)%'
#esi: true
#fragments: true

View File

@@ -1,6 +1,9 @@
twig:
globals:
version: '%app.version%'
file_name_pattern: '*.twig'
date:
format: 'm/d/Y'
timezone: '%env(default:app.default.timezone:TZ)%'
when@test:

View File

@@ -29,3 +29,19 @@ controllersMonitor:
type: attribute
defaults:
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

@@ -22,8 +22,11 @@ parameters:
app.cache.redis.host.default: 'redis://redis'
# Various configs
app.default.version: '0.dev'
app.default.timezone: 'America/Chicago'
app.version: '%env(default:app.default.version:APP_VERSION)%'
services:
# default configuration for services in *this* file
_defaults:

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \

View File

@@ -4,6 +4,9 @@ ENV SERVER_NAME=":80"
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
ENV APP_RUNTIME="Runtime\\FrankenPhpSymfony\\Runtime"
ARG APP_VERSION="0.dev"
ENV APP_VERSION="${APP_VERSION}"
RUN install-php-extensions \
pdo_mysql \
gd \

View File

@@ -3,8 +3,6 @@
frankenphp {
{$FRANKENPHP_CONFIG}
num_threads 10
}
}

View File

@@ -27,7 +27,7 @@ DATABASE_URL="mysql://root:password@database:3306/app?serverVersion=10.6.19.2-Ma
# Popular Movies and TV Shows section.
#TMDB_API=
REAL_DEBRID_KEY="QYYBR7OSQ4VEFKWASDEZ2B4VO67KHUJY6IWOT7HHA7ATXO7QCYDQ"
REAL_DEBRID_KEY=""
TMDB_API=eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0ZTJjYjJhOGUzOGJhNjdiNjVhOGU1NGM0ZWI1MzhmOCIsIm5iZiI6MTczNzkyNjA0NC41NjQsInN1YiI6IjY3OTZhNTljYzdiMDFiNzJjNzIzZWM5YiIsInNjb3BlcyI6WyJhcGlfcmVhZCJdLCJ2ZXJzaW9uIjoxfQ.e8DbNe9qrSBC1y-ANRv-VWBAtls-ZS2r7aNCiI68mpw

View File

@@ -11,6 +11,13 @@ services:
- '8006:80'
env_file:
- .env
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
depends_on:
database:
condition: service_healthy
@@ -27,6 +34,8 @@ services:
volumes:
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
environment:
TZ: America/Chicago
command: -vvv
env_file:
- .env
@@ -43,35 +52,17 @@ services:
scheduler:
image: code.caldwell.digital/home/torsearch-scheduler:latest
volumes:
- ./downloads:/var/download
- /mnt/media/downloads/movies:/var/download/movies
- /mnt/media/downloads/tvshows:/var/download/tvshows
env_file:
- .env
environment:
TZ: America/Chicago
restart: always
depends_on:
app:
condition: service_healthy
# This container facilitates viewing the progress of downloads
# in realtime. It also handles sending alerts and notifications.
# The MERCURE_PUBLISHER_JWT key & MERCURE_SUBSCRIBER_JWT_KEY should
# match the MERCURE_JWT_SECRET environment variable.
mercure:
image: dunglas/mercure
restart: unless-stopped
ports:
- "3001:80"
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_EXTRA_DIRECTIVES: |
cors_origins *
anonymous
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
volumes:
- mercure_data:/data
- mercure_config:/config
database:
image: mariadb:10.11.2
volumes:

View File

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

View File

@@ -3,7 +3,11 @@
namespace App\Controller;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -13,6 +17,7 @@ final class IndexController extends AbstractController
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly MonitorTvShowHandler $monitorTvShowHandler,
) {}
#[Route('/', name: 'app_index')]

View File

@@ -33,14 +33,13 @@ final class SearchController extends AbstractController
]);
}
#[Route('/result/{mediaType}/{imdbId}', name: 'app_search_result')]
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')]
public function result(
GetMediaInfoInput $input,
?int $season = null,
): Response {
$result = $this->getMediaInfoHandler->handle($input->toCommand());
$this->warmDownloadOptionCache($result->media);
return $this->render('search/result.html.twig', [
'results' => $result,
'filter' => [

View File

@@ -26,6 +26,9 @@ class DownloadMediaInput implements InputInterface
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('episodeId', nullify: true)]
public ?string $episodeId = null,
public ?int $userId = null,
public ?int $downloadId = null,

View File

@@ -11,6 +11,7 @@ use App\Download\Action\Input\PauseDownloadInput;
use App\Download\Action\Input\ResumeDownloadInput;
use App\Download\Framework\Repository\DownloadRepository;
use App\Util\Broadcaster;
use Nihilarr\PTN;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -28,6 +29,13 @@ class ApiController extends AbstractController
public function download(
DownloadMediaInput $input,
): Response {
$ptn = (object) new Ptn()->parse($input->filename);
if ($input->mediaType === "tvshows" &&
!property_exists($ptn, 'episode') && !property_exists($ptn, 'season')
) {
$input->filename = $input->episodeId . '_' . $input->filename;
}
$download = $this->downloadRepository->insert(
$this->getUser(),
$input->url,

View File

@@ -10,6 +10,7 @@ use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Monitor\Service\MediaFiles;
use App\Tmdb\Tmdb;
use Carbon\Carbon;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
@@ -55,9 +56,18 @@ readonly class MonitorTvShowHandler implements HandlerInterface
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle());
$episodeMonitors = [];
if ($downloadedEpisodes->count() !== $episodesInShow->count()) {
// Dispatch Episode commands for each missing Episode
foreach ($episodesInShow as $episode) {
// Only monitor future episodes
$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'));
if (false === $episodeInFuture) {
$this->logger->info('> [MonitorTvShowHandler] Episode released after monitor started for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping');
continue;
}
// Check if the episode is already downloaded
$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'));
@@ -91,6 +101,8 @@ readonly class MonitorTvShowHandler implements HandlerInterface
$this->monitorRepository->getEntityManager()->persist($episodeMonitor);
$this->monitorRepository->getEntityManager()->flush();
$episodeMonitors[] = $episodeMonitor;
// Immediately run the monitor
$command = new MonitorTvEpisodeCommand($episodeMonitor->getId());
$this->monitorTvEpisodeHandler->handle($command);
@@ -108,10 +120,18 @@ readonly class MonitorTvShowHandler implements HandlerInterface
status: 'OK',
result: [
'monitor' => $monitor,
'new_monitors' => $episodeMonitors,
]
);
}
private function episodeReleasedAfterMonitorCreated(string|DateTimeImmutable $monitorStartDate, array $episodeInShow): bool
{
$monitorStartDate = Carbon::parse($monitorStartDate)->setTime(0, 0);
$episodeAirDate = Carbon::parse($episodeInShow['air_date']);
return $episodeAirDate >= $monitorStartDate;
}
private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool
{
return $downloadedEpisodes->filter(

View File

@@ -2,17 +2,15 @@
namespace App\Monitor\Framework\Controller;
use App\Download\Action\Input\DeleteDownloadInput;
use App\Monitor\Action\Handler\AddMonitorHandler;
use App\Monitor\Action\Handler\DeleteMonitorHandler;
use App\Monitor\Action\Input\AddMonitorInput;
use App\Monitor\Action\Input\DeleteMonitorInput;
use App\Monitor\Framework\Scheduler\MonitorDispatcher;
use App\Util\Broadcaster;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
@@ -46,22 +44,23 @@ class ApiController extends AbstractController
public function deleteMonitor(
DeleteMonitorInput $input,
DeleteMonitorHandler $handler,
HubInterface $hub,
) {
$response = $handler->handle($input->toCommand());
$hub->publish(new Update(
'alerts',
$this->renderer->render('broadcast/Alert.stream.html.twig', [
'alert_id' => uniqid(),
'title' => 'Success',
'message' => "New monitor added for {$response->monitor->getTitle()}",
])
));
return $this->json([
'status' => 200,
'message' => $response
]);
}
#[Route('/api/monitor/dispatch', name: 'api_monitor_dispatch', methods: ['GET'])]
public function dispatch(MonitorDispatcher $dispatcher): Response
{
$dispatcher();
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\User\Framework\Entity\User;
use Carbon\Carbon;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -148,7 +149,7 @@ class Monitor
public function getLastSearch(): ?\DateTimeInterface
{
return $this->lastSearch;
return Carbon::parse($this->lastSearch);
}
public function setLastSearch(?\DateTimeInterface $lastSearch): static

View File

@@ -7,6 +7,7 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Framework\Repository\MonitorRepository;
use Carbon\Carbon;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
@@ -23,6 +24,8 @@ class MonitorDispatcher
public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$this->cleanupStuckMonitors();
$monitorHandlers = [
'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class,
@@ -41,4 +44,18 @@ class MonitorDispatcher
$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

@@ -10,5 +10,6 @@ class GetMediaInfoCommand implements CommandInterface
public function __construct(
public string $imdbId,
public string $mediaType,
public ?int $season = null,
) {}
}

View File

@@ -20,6 +20,6 @@ class GetMediaInfoHandler implements HandlerInterface
{
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
return new GetMediaInfoResult($media);
return new GetMediaInfoResult($media, $command->season);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Search\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Enum\MediaType;
use App\Search\Action\Command\GetMediaInfoCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
@@ -17,10 +18,16 @@ class GetMediaInfoInput implements InputInterface
#[SourceRoute('mediaType')]
public string $mediaType,
#[SourceRoute('season', nullify: true)]
public ?int $season,
) {}
public function toCommand(): CommandInterface
{
return new GetMediaInfoCommand($this->imdbId, $this->mediaType);
if ("tvshows" === $this->mediaType && null === $this->season) {
$this->season = 1;
}
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season);
}
}

View File

@@ -10,5 +10,6 @@ class GetMediaInfoResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public ?int $season,
) {}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Search;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\Action\Result\GetMediaInfoResult;
use stdClass;
class TvEpisodePaginator
{
/**
* @var integer
*/
private $total;
/**
* @var integer
*/
private $lastPage;
private $items;
public $limit = 20;
public $currentPage = 1;
public function paginate(GetMediaInfoResult $results, int $page = 1, int $limit = 20): static
{
$this->total = count($results->media->episodes[$results->season]);
$this->lastPage = (int) ceil($this->total / $limit);
$this->items = array_slice($results->media->episodes[$results->season], ($page - 1) * $limit, $limit);
$this->currentPage = $page;
$this->limit = $limit;
return $this;
}
public function getTotal(): int
{
return $this->total;
}
public function getLastPage(): int
{
return $this->lastPage;
}
public function getItems()
{
return $this->items;
}
public function getShowing()
{
$showingStart = (($this->currentPage - 1) * $this->limit) + 1;
$showingEnd = (($this->currentPage - 1) * $this->limit) + $this->limit;
if ($showingEnd > $this->total) {
$showingEnd = $this->total;
}
return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total);
}
}

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

@@ -2,8 +2,12 @@
namespace App\Tmdb;
use Aimeos\Map;
use App\Enum\MediaType;
use App\ValueObject\ResultFactory;
use Carbon\Carbon;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\CacheInterface;
@@ -11,6 +15,7 @@ use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
@@ -39,7 +44,7 @@ class Tmdb
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
private readonly CacheInterface $cache,
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
) {
@@ -70,7 +75,13 @@ class Tmdb
/**
* Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
*/
$requestListener = new RequestListener($this->client->getHttpClient(), $this->eventDispatcher);
$requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(),
$this->eventDispatcher,
$cache,
$this->client->getHttpClient()->getPsr17StreamFactory(),
[]
);
$this->eventDispatcher->addListener(RequestEvent::class, $requestListener);
$apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
@@ -97,6 +108,16 @@ class Tmdb
return $this->parseResult($movies[$movie], "movie");
});
$movies = Map::from($movies->toArray())->filter(function ($movie) {
return $movie !== null
&& $movie->imdbId !== null
&& $movie->tmdbId !== null
&& $movie->title !== null
&& $movie->poster !== null
&& $movie->description !== null
&& $movie->mediaType !== null;
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
@@ -114,6 +135,16 @@ class Tmdb
return $this->parseResult($movies[$movie], "movie");
});
$movies = Map::from($movies->toArray())->filter(function ($movie) {
return $movie !== null
&& $movie->imdbId !== null
&& $movie->tmdbId !== null
&& $movie->title !== null
&& $movie->poster !== null
&& $movie->description !== null
&& $movie->mediaType !== null;
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
@@ -188,7 +219,12 @@ class Tmdb
continue;
}
$series['episodes'][$season['season_number']] = $client->getApi()->getSeason($series['id'], $season['season_number'])['episodes'];
$series['episodes'][$season['season_number']] = Map::from(
$client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']
)->map(function ($data) {
$data['poster'] = (null !== $data['still_path']) ? self::POSTER_IMG_PATH . $data['still_path'] : null;
return $data;
})->toArray();
}
return $series;
}

View File

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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Torrentio\Exception;
class TorrentioRateLimitException extends \Exception
{
public function __construct()
{
parent::__construct(sprintf("[TorrentioClient] Rate limit exceeded"));
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Client\Torrentio;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class ApiController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
private readonly Broadcaster $broadcaster,
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),
);
}
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.movies.%s.%s",
$input->tmdbId,
$input->imdbId
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
try {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
// });
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]
public function clearTvShowOptions(GetTvShowOptionsInput $input, CacheInterface $cache, Request $request): Response
{
$cacheId = sprintf(
"page.torrentio.tvshows.%s.%s.%s.%s",
$input->tmdbId,
$input->imdbId,
$input->season,
$input->episode,
);
$cache->delete($cacheId);
$this->broadcaster->alert(
title: 'Success',
message: 'Torrentio cache Cleared.'
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
}
}

View File

@@ -1,11 +1,12 @@
<?php
namespace App\Controller;
namespace App\Torrentio\Framework\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use App\Torrentio\Exception\TorrentioRateLimitException;
use App\Util\Broadcaster;
use Carbon\Carbon;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,7 +16,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class TorrentioController extends AbstractController
final class WebController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
@@ -52,13 +53,24 @@ final class TorrentioController extends AbstractController
$input->episode,
);
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
// });
try {
// return $cache->get($cacheId, function (ItemInterface $item) use ($input) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
// });
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',
[],
new Response('Too many requests',
Response::HTTP_TOO_MANY_REQUESTS,
['Retry-After' => 4000]
)
);
}
}
#[Route('/torrentio/tvshows/clear/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_clear_torrentio_tvshows')]

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Twig\Components;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\TvEpisodePaginator;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent]
final class TvEpisodeList
{
use DefaultActionTrait;
use PaginateTrait;
#[LiveProp(writable: true)]
public string $title = "";
#[LiveProp(writable: true)]
public string $imdbId = "";
#[LiveProp(writable: true)]
public string $tmdbId = "";
#[LiveProp(writable: true)]
public int $season = 1;
public function __construct(
private GetMediaInfoHandler $getMediaInfoHandler,
) {}
public function getEpisodes()
{
$results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season));
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
}
public function setPage(int $page)
{
$this->pageNumber = $page;
}
}

View File

@@ -4,8 +4,12 @@ namespace App\Twig\Extensions;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Torrentio\Result\TorrentioResult;
use ChrisUllyott\FileSize;
use Tmdb\Model\Tv\Episode;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
class UtilExtension
{
@@ -14,6 +18,21 @@ class UtilExtension
private readonly MediaFiles $mediaFiles,
) {}
#[AsTwigFunction('uniqid')]
public function uniqid(): string
{
return uniqid();
}
#[AsTwigFilter('truncate')]
public function truncate(string $text)
{
if (strlen($text) > 300) {
$text = substr($text, 0, 300) . '...';
}
return $text;
}
#[AsTwigFilter('filesize')]
public function type(string|int $size)
{
@@ -29,4 +48,15 @@ class UtilExtension
$path
);
}
#[AsTwigFilter('episode_id_from_results')]
public function episodeId($result): ?string
{
if (!$result instanceof GetTvShowOptionsResult) {
return null;
}
return "S". str_pad($result->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($result->episode, 2, "0", STR_PAD_LEFT);
}
}

View File

@@ -17,13 +17,16 @@ use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
public function __construct(private readonly RegisterUserHandler $registerUserHandler)
public function __construct(private readonly RegisterUserHandler $registerUserHandler,
private readonly RequestStack $requestStack
)
{
}
@@ -71,6 +74,7 @@ class RegistrationController extends AbstractController
));
$security->login($user->user);
$this->requestStack->getCurrentRequest()->getSession()->set('mercure_alert_topic', 'alerts_' . uniqid());
return $this->redirectToRoute('app_index');
}

View File

@@ -17,7 +17,7 @@ readonly class Broadcaster
private RequestStack $requestStack,
) {}
public function alert(string $title, string $message): void
public function alert(string $title, string $message, string $type = "success"): void
{
$userAlertTopic = $this->requestStack->getCurrentRequest()->getSession()->get('mercure_alert_topic');
$update = new Update(
@@ -26,6 +26,7 @@ readonly class Broadcaster
'alert_id' => uniqid(),
'title' => $title,
'message' => $message,
'type' => $type,
])
);
$this->hub->publish($update);

View File

@@ -22,6 +22,8 @@ class Paginator
public $currentPage = 1;
public $limit = 5;
/**
* @param QueryBuilder|Query $query
* @param int $page
@@ -41,6 +43,7 @@ class Paginator
$this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults());
$this->items = $paginator;
$this->currentPage = $page;
$this->limit = $limit;
return $this;
}
@@ -59,4 +62,11 @@ class Paginator
{
return $this->items;
}
public function getShowing()
{
$showingStart = ($this->currentPage - 1) * $this->limit;
$showingEnd = $showingStart + $this->limit;
return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total);
}
}

View File

@@ -281,6 +281,18 @@
"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": {
"version": "2.24",
"recipe": {

View File

@@ -5,6 +5,11 @@ module.exports = {
"./templates/**/*.html.twig",
],
safelist: [
"flex",
"flex-col",
"flex-row",
"p-2",
"p-4",
"bg-blue-300",
"bg-orange-300",
"bg-fuchsia-300",
@@ -13,10 +18,21 @@ module.exports = {
"bg-orange-400",
"bg-blue-600",
"bg-rose-600",
"alert-success",
"alert-warning",
"min-w-64",
"rotate-180",
"-rotate-180",
"transition-opacity",
"ease-in",
"duration-700",
"opacity-100"
"opacity-100",
"table-row",
"max-w-[60ch]",
"truncate",
"text-wrap",
"rounded-sm",
"rounded-md"
],
theme: {
extend: {

View File

@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}

View File

@@ -2,6 +2,7 @@
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
@@ -13,13 +14,13 @@
{% endblock %}
</head>
<body class="flex flex-col bg-stone-700">
<div class="grid grid-cols-6">
<div class="col-span-1 h-screen">
<div class="grid md:grid-cols-6">
<div class="hidden md:block md:col-span-1 md:h-screen">
<twig:NavBar />
</div>
<div class="col-span-5 h-screen overflow-y-scroll">
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<twig:Header />
<h2 class="px-4 my-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
{% block body %}{% endblock %}
</div>
</div>

View File

@@ -1,5 +1,5 @@
<turbo-stream action="prepend" target="alert_list">
<template>
<twig:Alert :title="title|default('')" :message="message" :alert_id="alert_id" data-controller="alert" />
<twig:Alert :title="title|default('')" :message="message" :alert_id="alert_id" type="{{ type|default('success') }}" data-controller="alert" />
</template>
</turbo-stream>

View File

@@ -1,7 +1,5 @@
<li {{ attributes }} id="alert_{{ alert_id }}" class="
text-white bg-green-950 text-sm min-w-[250px]
hover:bg-green-900 border border-green-500 px-4 py-3
rounded-md"
<li {{ attributes }} id="alert_{{ alert_id }}"
class="alert alert-{{ type|default('success') }}"
role="alert"
>
<div class="flex items-center">

View File

@@ -1,6 +1,6 @@
<div{{ attributes }}>
<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">
<h3 class="mb-4 text-lg font-bold text-white">

View File

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

View File

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

View File

@@ -4,8 +4,9 @@
data-result-filter-media-type-value="{{ results.media.mediaType }}"
data-result-filter-movie-results-outlet=".results"
data-result-filter-tv-results-outlet=".results"
data-result-filter-tv-episode-list-outlet=".episode-list"
>
<div class="w-full p-4 flex flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<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">
Resolution
<select id="resolution"
@@ -58,7 +59,9 @@
<label for="season">
Season
<select id="season" name="season" value="1" data-result-filter-target="season" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}>
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
>
<option selected value="1">1</option>
{% for season in range(2, results.media.episodes|length) %}
<option value="{{ season }}">{{ season }}</option>

View File

@@ -1,16 +1,26 @@
<header {{ attributes }} class="bg-cyan-950 z-40">
<div class="px-4 sm:px-6 lg:px-8">
<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 />
<div class="md:flex md:items-center md:gap-12">
<nav aria-label="Global" class="md:block">
<ul class="flex items-center gap-6 text-sm">
<li><twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/></li>
<li>
<li class="hidden">
<twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/>
</li>
<li class="hidden md:block">
<a href="{{ path('app_logout') }}">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a>
</li>
<li {{ stimulus_controller('hamburger') }}
{{ stimulus_action('hamburger', 'toggleMenu', 'click') }}
data-hamburger-navbar-outlet="#navbar"
id="hamburger" class="cursor-pointer md:hidden"
>
<svg xmlns="http://www.w3.org/2000/svg" class="text-orange-500 ml-4" width="25px" height="25px" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M0 3.75A.75.75 0 0 1 .75 3h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 3.75M0 8a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8m.75 3.5a.75.75 0 0 0 0 1.5h14.5a.75.75 0 0 0 0-1.5z" clip-rule="evenodd"/></svg>
</li>
</ul>
</nav>
</div>

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950">
<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">
<h1 class="text-3xl font-extrabold text-orange-500 mb-3">Torsearch</h1>
<ul class="space-y-1">
<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="nav-list space-y-1">
<li>
<a href="{{ path('app_index') }}"
class="block rounded-lg
@@ -38,13 +38,13 @@
</ul>
</div>
<div class="sticky inset-x-0 bottom-0 border-t border-orange-500">
<a href="#" class="nav-foot flex items-center gap-2 p-4 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60">
<div class="sticky inset-x-0 bottom-0 border-t border-b border-orange-500 bg-orange-500 hover:bg-opacity-80 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 flex flex-col">
<a href="#" class="nav-foot flex items-center gap-2 pt-4 px-4">
<span class="rounded-full p-2 border-orange-500 border-2">
<twig:ux:icon name="ri:user-line" width="30" class="text-gray-50"/>
</span>
<div>
<div class="flex flex-col text-white">
<p class="text-xs">
{% if app.user.name %}
<strong class="block font-medium text-white">{{ app.user.name }}</strong>
@@ -54,5 +54,8 @@
</p>
</div>
</a>
<p class="px-4 pt-1 inline-flex justify-center">
<small class="text-white text-xs">v{{ version|default('0.0') }}</small>
</p>
</div>
</nav>

View File

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

View File

@@ -1,15 +1,24 @@
<div {{ attributes }} class="w-full max-w-sm min-w-[200px]">
<div class="relative">
<form action="{{ path('app_search') }}">
<input
<form id="search" action="{{ path('app_search') }}">
<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"
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
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
shadow-sm focus:shadow"
shadow-sm focus:shadow ts-search z-40"
placeholder="TV Show, Movie..."
/>
>
</select>
<button
class="absolute top-1 right-1 flex items-center rounded
bg-green-600 py-1 px-2.5 border border-transparent text-center
@@ -18,7 +27,7 @@
text-white bg-green-600 text-sm
border border-green-500
backdrop-filter backdrop-blur-md bg-opacity-80
backdrop-filter backdrop-blur-md bg-opacity-80 z-40
"
type="submit"
>

View File

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

View File

@@ -0,0 +1,80 @@
<div{{ attributes.defaults(stimulus_controller('tv_episode_list')) }}
class="episode-list flex flex-col gap-4"
>
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
{% for episode in this.episodes.items %}
<div id="episode_{{ episode['season_number'] }}_{{ episode['episode_number'] }}" class="results"
data-tv-results-loading-icon-outlet=".loading-icon"
data-download-button-outlet=".download-btn"
{{ stimulus_controller('tv_results', {
title: this.title,
tmdbId: this.tmdbId,
imdbId: this.imdbId,
season: episode['season_number'],
episode: episode['episode_number'],
active: 'true',
}) }}
>
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="flex flex-col md:flex-row gap-4">
{% if episode['poster'] != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />
{% else %}
<div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold">
{{ episode['episode_number'] }}. {{ episode['name'] }}
</h4>
<p>{{ episode['overview']|truncate }}</p>
<div>
<button class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"
{{ stimulus_action('tv-results', 'toggleList', 'click') }}
>
<span {{ stimulus_target('tv-results', 'count') }}>-</span> results
</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'] }}">
{{ episode['air_date']|date }}
</small>
</div>
</div>
<div class="flex flex-col gap-4 justify-between">
<div class="flex flex-col items-center">
<input type="checkbox"
{{ stimulus_target('tv-results', 'episodeSelector') }}
/>
</div>
<button class="flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90"
{{ stimulus_target('tv-results', 'toggleButton') }}
{{ stimulus_action('tv-results', 'toggleList', 'click') }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 6L10 16l10 10" />
</svg>
</button>
</div>
</div>
<div {{ stimulus_target('tv-results', 'listContainer') }} class="inline-block overflow-hidden rounded-lg">
</div>
</div>
</div>
{% endfor %}
</div>
{% set paginator = this.episodes %}
{% include 'partial/tv-episode-list-paginator.html.twig' %}
</div>
{% macro placeholder(props) %}
<span>
<twig:ux:icon name="codex:loader" height="40" width="40" data-loading-icon-target="icon" class="text-end" />
</span>
{% endmacro %}

View File

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

View File

@@ -1,11 +1,11 @@
{% extends 'base.html.twig' %}
{% block title %}Dashboard &mdash; Torsearch{% endblock %}
{% block h2 %}Dashboard{% endblock %}
{% block body %}
<div class="p-4 flex flex-col grow gap-4 z-30">
<h2 class="mb-2 text-3xl font-bold text-gray-50">Dashboard</h2>
<div class="flex flex-row gap-4">
<div class="p-4 flex flex-col grow gap-4 z-10">
<div class="flex flex-col md:flex-row gap-4">
<twig:Card title="Active Downloads" class="w-full">
<twig:DownloadList :type="'active'" />
</twig:Card>
@@ -14,13 +14,13 @@
<twig:DownloadList :type="'complete'" />
</twig:Card>
</div>
<div class="flex flex-row gap-4">
<div class="flex flex-col md:flex-row gap-4">
<twig:Card title="Monitors" class="w-full">
<twig:MonitorList :type="'active'" :isWidget="true" />
</twig:Card>
</div>
<div class="flex flex-col gap-4">
<twig:Card title="Popular Movies" contentClass="flex flex-row justify-between w-full">
<twig:Card title="Popular Movies" contentClass="flex flex-col gap-4 md:flex-row md:justify-between w-full">
{% for movie in popular_movies %}
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
@@ -32,7 +32,7 @@
/>
{% endfor %}
</twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-row justify-between w-full">
<twig:Card title="Popular TV Shows" contentClass="flex flex-col md:flex-row justify-between w-full">
{% for movie in popular_tvshows %}
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"

View File

@@ -4,22 +4,14 @@
{% block h2 %}Monitors{% endblock %}
{% block body %}
<div class="flex flex-row">
<div class="p-2 flex flex-col gap-4">
<twig:Card title="Active Monitors">
<twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
<twig:Card title="Complete Monitors">
<twig:MonitorList :type="'complete'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
<div class="p-2">
<twig:Card title="Upcoming Episodes" >
<twig:UpcomingEpisodes />
</twig:Card>
</div>
<div class="px-4 py-2">
<twig:Card title="Active Monitors">
<twig:MonitorList :type="'active'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
<div class="px-4 py-2">
<twig:Card title="Complete Monitors">
<twig:MonitorList :type="'complete'" :isWidget="false" :perPage="10"></twig:MonitorList>
</twig:Card>
</div>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% set _currentPage = paginator.currentPage ?: 1 %}
{% set _lastPage = paginator.lastPage %}
{% set _showingPerPage = (_currentPage == _lastPage) ? paginator.total - (this.perPage * (_lastPage - 1)) : paginator.items.query.maxResults %}
{% set _showingPerPage = (_currentPage == _lastPage) ? paginator.total - (this.perPage * (_lastPage - 1)) : ("query" in paginator.items) ? paginator.items.query.maxResults %}
<p class="text-white mt-1">Showing {{ _showingPerPage }} of {{ paginator.total }} total results</p>
@@ -8,75 +8,74 @@
<nav>
<ul class="mt-2 flex flex-row justify-content-center gap-1 py-1 text-white text-sm">
<li class="page-item{{ _currentPage <= 1 ? ' disabled' : '' }}">
<a {% if _currentPage > 1 %}
<button {% if _currentPage > 1 %}
data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ _currentPage - 1 }}"
{% endif %}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Previous"
href="#"
>
&laquo;
</a>
</button>
</li>
{% set startPage = max(1, _currentPage - 2) %}
{% set endPage = min(_lastPage, startPage + 4) %}
{% if startPage > 1 %}
<li class="page-item">
<a data-action="click->live#action"
<button data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ "1"|number_format }}"
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
href="#"
>1</a>
>1</button>
</li>
{% if startPage > 2 %}
<li class="page-item disabled">
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle">...</span>
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle inline-flex items-stretch">...</span>
</li>
{% endif %}
{% endif %}
{% for i in startPage..endPage %}
<li class="page-item}">
<a data-action="click->live#action"
<li class="page-item">
<button data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ i|number_format }}"
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 text-white align-middle"
{% if i == _currentPage %}style="background-color: #fff; color: darkorange; border: 2px solid darkorange;"{% endif %}
href="#"
>{{ i }}</a>
{% if i == _currentPage %}style="background-color: #fff; color: darkorange; font-weight: bold;"{% endif %}
>{{ i }}</button>
</li>
{% endfor %}
{% if endPage < _lastPage %}
{% if endPage < _lastPage - 1 %}
<li class="page-item disabled">
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle">...</span>
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle inline-flex items-stretch">...</span>
</li>
{% endif %}
<li class="page-item">
<a data-action="click->live#action"
<button data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ _lastPage }}"
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
href="#"
>{{ _lastPage }}</a>
>{{ _lastPage }}</button>
</li>
{% endif %}
<li class="page-item {{ _currentPage >= paginator.lastPage ? ' disabled' : '' }}">
<a {% if _currentPage < _lastPage %}
<button {% if _currentPage < _lastPage %}
data-action="click->live#action"
data-live-action-param="paginate"
data-live-page-param="{{ _currentPage + 1 }}"
{% endif %}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
href="#"
>
&raquo;
</a>
</button>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,74 @@
{% set _currentPage = paginator.currentPage ?: 1 %}
{% set _lastPage = paginator.lastPage %}
{% set _showingPerPage = (_currentPage == _lastPage) ? paginator.total - (this.perPage * (_lastPage - 1)) : ("query" in paginator.items) ? paginator.items.query.maxResults %}
<p class="text-white mt-1">{{ paginator.getShowing() }}</p>
{% if paginator.lastPage > 1 %}
<nav>
<ul class="mt-2 flex flex-row justify-content-center gap-1 py-1 text-white text-sm">
<li class="page-item{{ _currentPage <= 1 ? ' disabled' : '' }}">
<button
{% if _currentPage > 1 %}
{{ stimulus_action('tv-episode-list', 'paginate', 'click', {page: _currentPage - 1}) }}
{% endif %}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Previous"
>
&laquo;
</button>
</li>
{% set startPage = max(1, _currentPage - 2) %}
{% set endPage = min(_lastPage, startPage + 4) %}
{% if startPage > 1 %}
<li class="page-item">
<button
{{ stimulus_action('tv-episode-list', 'paginate', 'click', {page: 1}) }}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
>1</button>
</li>
{% if startPage > 2 %}
<li class="page-item disabled">
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle inline-flex items-stretch">...</span>
</li>
{% endif %}
{% endif %}
{% for i in startPage..endPage %}
<li class="page-item">
<button
{{ stimulus_action('tv-episode-list', 'paginate', 'click', {page: i}) }}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 text-white align-middle"
{% if i == _currentPage %}style="background-color: #fff; color: darkorange; font-weight: bold;"{% endif %}
>{{ i }}</button>
</li>
{% endfor %}
{% if endPage < _lastPage %}
{% if endPage < _lastPage - 1 %}
<li class="page-item disabled">
<span class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle inline-flex items-stretch">...</span>
</li>
{% endif %}
<li class="page-item">
<button
{{ stimulus_action('tv-episode-list', 'paginate', 'click', {page: _lastPage}) }}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
>{{ _lastPage }}</button>
</li>
{% endif %}
<li class="page-item {{ _currentPage >= paginator.lastPage ? ' disabled' : '' }}">
<button
{% if _currentPage < _lastPage %}
{{ stimulus_action('tv-episode-list', 'paginate', 'click', {page: _currentPage + 1}) }}
{% endif %}
class="page-link px-2.5 py-1 rounded-lg bg-orange-500 align-middle"
aria-label="Next"
>
&raquo;
</button>
</li>
</ul>
</nav>
{% endif %}

View File

@@ -7,11 +7,11 @@
<h2 class="mb-2 text-3xl font-bold text-gray-50">Media Results</h2>
<div class="flex flex-row w-full gap-2">
<twig:Card title="" contentClass="flex flex-col gap-4 justify-between w-full text-gray-50">
<div class="p-4 flex flex-row gap-6">
<div class="p-2 md:p-4 flex flex-col md:flex-row gap-6">
{% if results.media.poster != null %}
<img class="w-40 rounded-lg" src="{{ results.media.poster }}" />
<img class="w-full md:w-40 rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<div class="w-full md:w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
@@ -118,24 +118,29 @@
<div class="results" {{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}>
</div>
{% elseif "tvshows" == results.media.mediaType %}
{% for season, episodes in results.media.episodes %}
{% set active = (season == '1') ? true : false %}
{% for episode in episodes %}
<div class="results {{ (active == false) ? 'hidden' }}"
data-tv-results-loading-icon-outlet=".loading-icon"
data-download-button-outlet=".download-btn"
{{ stimulus_controller('tv_results', {
title: results.media.title,
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
season: season,
episode: episode['episode_number'],
active: active,
}) }}></div>
{% endfor %}
{% endfor %}
<twig:TvEpisodeList
results="results"
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer"
/>
{% endif %}
</twig:Card>
</div>
</div>
<style>
html,
body {
height: 100%;
}
@media (min-width: 640px) {
thead tr:not(:first-child) {
display: none;
}
}
td:not(:last-child) {
border-bottom: 0;
}
</style>
{% endblock %}

View File

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

View File

@@ -1,72 +1 @@
<div class="p-6 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<div class="flex flex-row gap-4">
{% if results.media.poster != null %}
<img class="w-64 rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-64 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" />
</div>
{% endif %}
<div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold">
{{ results.episode }}. {{ results.media.title }}
</h4>
<p>{{ results.media.description }}</p>
<div>
<button class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"
{{ stimulus_action('tv-results', 'toggleList', 'click') }}
>
<span {{ stimulus_target('tv-results', 'count') }}>{{ results.results|length }}</span> results
</button>
{% if results.file != false %}
<span data-controller="popover">
<template data-popover-target="content">
<div data-popover-target="card" class="absolute z-40 p-1 bg-stone-400 p-1 text-black rounded-md m-1 animate-fade">
<p class="font-bold text-sm text-left">Existing file(s) for this episode:</p>
<ul class="list-disc ml-3">
<li class="font-normal">{{ results.file.realPath|strip_media_path }} &mdash; <strong>{{ results.file.size|filesize }}</strong></li>
</ul>
</div>
</template>
<small
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide"
>
exists
</small>
</span>
{% endif %}
{% if results.file == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
missing
</small>
{% endif %}
<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 {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate }}
</small>
{# <small class="py-1 px-1.5 grow-0 font-bold bg-red-600 hover:bg-red-700 rounded-lg font-normal text-white cursor-pointer" title="Clear cache for {{ results.media.title }}"#}
{# {{ stimulus_action('tv-results', 'clearCache', 'click') }}#}
{# >Clear Cache</small>#}
</div>
</div>
<div class="flex flex-col gap-4 justify-between">
<div class="flex flex-col items-center">
<input type="checkbox"
{{ stimulus_target('tv-results', 'episodeSelector') }}
/>
</div>
<button class="flex flex-col items-end"
{{ stimulus_action('tv-results', 'toggleList', 'click') }}>
<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 32 32">
<path fill="currentColor" d="m16 10l10 10l-1.4 1.4l-8.6-8.6l-8.6 8.6L6 20z"/>
</svg>
</button>
</div>
</div>
<div class="inline-block overflow-hidden rounded-lg">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}
</div>
</div>
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}

View File

@@ -3,7 +3,7 @@
{% block h2 %}Preferences{% endblock %}
{% 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">
<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') }}">