Compare commits

..

7 Commits

62 changed files with 407 additions and 858 deletions

View File

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

@@ -0,0 +1,30 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
initialize() {}
connect() {}
disconnect() {}
async clearAll() {
let response = await fetch('/api/torrentio/cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
type: 'torrentio',
mediaType: 'tvshows',
})
});
response = await response.json()
}
}

View File

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

View File

@@ -16,7 +16,6 @@ export default class extends Controller {
};
static targets = ['list']
static outlets = ['loading-icon']
options = []
optionsLoaded = false
@@ -34,8 +33,6 @@ export default class extends Controller {
this.element.innerHTML = response;
this.options = this.element.querySelectorAll('tbody tr');
this.options.forEach((option) => option.querySelector('.download-btn').dataset['title'] = this.titleValue);
this.dispatch('optionsLoaded', {detail: {options: this.options}})
this.loadingIconOutlet.toggleIcon();
});
}
}
@@ -55,12 +52,10 @@ export default class extends Controller {
"resolution": option.querySelector('#resolution').textContent.trim(),
"codec": option.querySelector('#codec').textContent.trim(),
"provider": option.querySelector('#provider').textContent.trim(),
"quality": option.dataset['quality'],
"languages": JSON.parse(option.dataset['languages']),
}
let include = true;
option.classList.add('r-tablerow');
option.classList.remove('hidden');
option.querySelector('input[type="checkbox"]').checked = false;
@@ -86,7 +81,6 @@ 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,21 +10,16 @@ export default class extends Controller {
activeStyles = "block rounded-lg bg-orange-500 hover:bg-opacity-70 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-80 px-4 py-2 text-sm font-medium text-gray-50";
connect() {
this.element.querySelectorAll('.nav-list a:not(.nav-foot)').forEach(link => {
console.log(window.location.pathname);
this.element.querySelectorAll('a:not(.nav-foot)').forEach(link => {
link.className = this.inactiveStyles;
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) => {
});
}
toggle() {
this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20');
setActive() {
}
}

View File

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

View File

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

View File

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

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

Before

Width:  |  Height:  |  Size: 333 B

View File

@@ -25,6 +25,13 @@
.alert-warning {
@apply bg-yellow-500/70 hover:bg-yellow-600 border-yellow-400 text-black
}
.primary-btn {
@apply px-1 py-1 rounded-md bg-orange-500 self-end text-white w-16 ml-2 hover:bg-orange-600
}
.secondary-btn {
@apply px-1 py-1 rounded-md self-end w-16 hover:bg-stone-100
}
}
/* Prevent scrolling while dialog is open */
@@ -63,62 +70,3 @@ 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

@@ -44,7 +44,6 @@
"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",

92
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "248d1e534ec6bb56594a7380fb2eb860",
"content-hash": "67e697578f7237f60726c0d93bfed001",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -9071,96 +9071,6 @@
],
"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,5 +19,4 @@ 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

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

View File

@@ -36,12 +36,5 @@ controllersTorrentio:
namespace: App\Torrentio\Framework\Controller
type: attribute
defaults:
schemes: ['https']
schemes: [ 'https' ]
controllersTmdb:
resource:
path: ../src/Tmdb/Framework/Controller
namespace: App\Tmdb\Framework\Controller
type: attribute
defaults:
schemes: ['https']

View File

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

View File

@@ -40,28 +40,4 @@ 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

@@ -2,10 +2,8 @@
namespace App\Command;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -20,17 +18,14 @@ class SeedDatabaseCommand extends Command
{
private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository;
private UserRepository $userRepository;
public function __construct(
PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository,
UserRepository $userRepository,
) {
parent::__construct();
$this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository;
$this->userRepository = $userRepository;
}
protected function execute(InputInterface $input, OutputInterface $output): int
@@ -39,7 +34,6 @@ class SeedDatabaseCommand extends Command
$this->seedPreferences($io);
$this->seedPreferenceOptions($io);
$this->updateUserPreferences($io);
return Command::SUCCESS;
}
@@ -66,26 +60,6 @@ class SeedDatabaseCommand extends Command
$this->preferenceRepository->getEntityManager()->flush();
}
private function updateUserPreferences(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Updating user preferences...');
$users = $this->userRepository->findAll();
$preferences = $this->preferenceRepository->findAll();
foreach ($users as $user) {
foreach ($preferences as $preference) {
if (false === $user->hasUserPreference($preference->getId())) {
$user->addUserPreference(
new UserPreference()
->setPreference($preference)
->setUser($user)
->setPreferenceValue(null)
);
}
}
}
$this->userRepository->getEntityManager()->flush();
}
private function getPreferences(): array
{
return [
@@ -117,13 +91,6 @@ class SeedDatabaseCommand extends Command
'enabled' => true,
'type' => 'media',
],
[
'id' => 'quality',
'name' => 'Quality',
'description' => null,
'enabled' => true,
'type' => 'media',
],
[
'id' => 'movie_folder',
'name' => 'Create new folder for Movies',

View File

@@ -5,10 +5,7 @@ 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 App\User\Framework\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -24,8 +21,6 @@ final class IndexController extends AbstractController
#[Route('/', name: 'app_index')]
public function index(Request $request): Response
{
/** @var User $user */
$user = $this->getUser();
return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(),
@@ -33,4 +28,11 @@ final class IndexController extends AbstractController
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}
#[Route('/test', name: 'app_test')]
public function test()
{
$result = $this->monitorTvShowHandler->handle(new MonitorTvShowCommand(355));
return $this->json($result);
}
}

View File

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

View File

@@ -6,10 +6,8 @@ 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\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -52,15 +50,4 @@ class ApiController extends AbstractController
'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,7 +4,6 @@ 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;
@@ -149,7 +148,7 @@ class Monitor
public function getLastSearch(): ?\DateTimeInterface
{
return Carbon::parse($this->lastSearch);
return $this->lastSearch;
}
public function setLastSearch(?\DateTimeInterface $lastSearch): static

View File

@@ -7,7 +7,6 @@ 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;
@@ -24,8 +23,6 @@ class MonitorDispatcher
public function __invoke() {
$this->logger->info('[MonitorDispatcher] Executing MonitorDispatcher');
$this->cleanupStuckMonitors();
$monitorHandlers = [
'movie' => MonitorMovieCommand::class,
'tvepisode' => MonitorTvEpisodeCommand::class,
@@ -44,18 +41,4 @@ 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

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

View File

@@ -44,7 +44,7 @@ class Tmdb
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
private readonly CacheItemPoolInterface $cache,
private readonly CacheItemPoolInterface $tmdbCache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
) {
@@ -78,7 +78,7 @@ class Tmdb
$requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(),
$this->eventDispatcher,
$cache,
$tmdbCache,
$this->client->getHttpClient()->getPsr17StreamFactory(),
[]
);
@@ -325,7 +325,7 @@ class Tmdb
public function getImdbId(string $tmdbId, $mediaType)
{
$externalIds = $this->cache->get("tmdb.externalIds.{$tmdbId}",
$externalIds = $this->tmdbCache->get("tmdb.externalIds.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
@@ -346,7 +346,7 @@ class Tmdb
public function getImages($tmdbId, $mediaType)
{
return $this->cache->get("tmdb.images.{$tmdbId}",
return $this->tmdbCache->get("tmdb.images.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,37 +2,20 @@
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 App\Torrentio\Action\Handler\DeleteCacheHandler;
use App\Torrentio\Action\Input\DeleteCacheInput;
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
class ApiController extends AbstractController
{
public function __construct(
private readonly Torrentio $torrentio,
) {}
#[Route('/api/torrentio/{imdbId}/{season?}/{episode?}', name: 'api_torrentio')]
public function api(string $imdbId, ?int $season, ?int $episode): Response
{
if (null !== $season && null !== $episode) {
return $this->json(
$this->torrentio->fetchEpisodeResults($imdbId, $season, $episode, false)
);
}
return $this->json(
$this->torrentio->search($imdbId, 'movies', false),
);
#[Route('/api/torrentio/cache', name: 'api.torrentio.cache', methods: ['POST'])]
public function deleteCache(
DeleteCacheInput $deleteCacheInput,
DeleteCacheHandler $deleteCacheHandler,
): Response {
$result = $deleteCacheHandler->handle($deleteCacheInput->toCommand());
return $this->json($result, Response::HTTP_OK);
}
}

View File

@@ -22,7 +22,6 @@ class ResultFactory
string $bingeGroup = "-"
) {
$ptn = (object) (new PTN())->parse($title);
// dump($ptn);
return new TorrentioResult(
self::trimTitle($title),
urldecode($url),
@@ -35,7 +34,6 @@ class ResultFactory
$bingeGroup,
$ptn->resolution ?? "-",
self::setCodec($ptn->codec ?? "-"),
$ptn->quality ?? "-",
$ptn,
substr(base64_encode($url), strlen($url) - 10),
$ptn->episode ?? "-",

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ use App\User\Framework\Repository\PreferencesRepository;
use App\Util\Broadcaster;
use App\Util\CountryLanguages;
use App\Util\ProviderList;
use App\Util\QualityList;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -37,8 +36,7 @@ class PreferencesController extends AbstractController
[
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'providers' => ProviderList::$providers,
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
]
@@ -69,7 +67,6 @@ class PreferencesController extends AbstractController
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences,
]

View File

@@ -209,7 +209,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return Map::from($this->userPreferences)
->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId())
->map(function (UserPreference $userPreference) {
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider', 'quality'])) {
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider'])) {
return $userPreference->getPreferenceValue();
}
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
<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,7 +2,6 @@
<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 %}
@@ -14,13 +13,18 @@
{% endblock %}
</head>
<body class="flex flex-col bg-stone-700">
<div class="grid md:grid-cols-6">
<div class="hidden md:block md:col-span-1 md:h-screen">
<div class="grid grid-cols-6">
<div class="col-span-1 h-screen">
<twig:NavBar />
</div>
<div class="col-span-6 md:col-span-5 h-screen overflow-y-scroll">
<div class="col-span-5 h-screen overflow-y-scroll">
<twig:Header />
<h2 class="px-4 mt-4 mb-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="px-4 mt-3 flex flex-row justify-between">
<h2 class="m-2 text-3xl font-bold text-gray-50">{% block h2 %}{% endblock %}</h2>
<div class="flex flex-row gap-1 align-end justify-end items-end">
{% block action_buttons %}{% endblock %}
</div>
</div>
{% block body %}{% endblock %}
</div>
</div>

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 z-10
backdrop-filter backdrop-blur-md bg-opacity-40
">
<div class="p-4 md:p-5">
<h3 class="mb-4 text-lg font-bold text-white">

View File

@@ -11,17 +11,21 @@
<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 truncate">
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-stone-800 {% if this.isWidget == true %}min-w-[45ch] max-w-[45ch]{% endif %} truncate">
Title
</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" }}">
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>
{% 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-gray-500 uppercase dark:text-stone-800">
Progress
@@ -34,7 +38,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="{{ isWidget }}" />
<twig:DownloadListRow download="{{ download }}" isWidget="{{ this.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 max-w-[10ch] md:max-w-[unset] truncate"
class="mr-1 hover:underline rounded-md"
>
{{ download.title }}
</a>
@@ -11,13 +11,14 @@
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 max-w-[60ch] {{ isWidget == true ? "hidden" : "r-tablecell" }} truncate">
{{ 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>
{% 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 align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %}
@@ -31,7 +32,7 @@
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center">
<td id="action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center">
{% if download.status == 'In Progress' and download.progress < 100 %}
<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

@@ -1,12 +1,12 @@
<div id="filter" class="flex flex-col gap-4"
{{ stimulus_controller('result_filter', {reverseMappedQualities: this.reverseMappedQualities}) }}
{{ stimulus_controller('result_filter') }}
{{ stimulus_action('result_filter', 'filter', 'change') }}
data-result-filter-media-type-value="{{ results.media.mediaType }}"
data-result-filter-movie-results-outlet=".results"
data-result-filter-tv-results-outlet=".results"
data-result-filter-tv-episode-list-outlet=".episode-list"
data-action="change->result-filter#filter movie-results:optionsLoaded@window->result-filter#loadOptions tv-results:optionsLoaded@window->result-filter#loadOptions"
>
<div class="w-full p-4 flex flex-col md:flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<div class="w-full p-4 flex flex-row gap-4 bg-stone-500 text-md text-gray-500 dark:text-gray-50 rounded-lg">
<label for="resolution">
Resolution
<select id="resolution"
@@ -55,17 +55,6 @@
>
</select>
</label>
<label for="quality">
Quality
<select id="quality"
data-result-filter-target="quality"
class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-md"
{% if this.userPreferences['quality'] != null %}
data-preferred="{{ this.userPreferences['quality'] }}"
{% endif %}
>
</select>
</label>
{% if results.media.mediaType == "tvshows" %}
<label for="season">
Season

View File

@@ -1,26 +1,16 @@
<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 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">
<li><twig:ux:icon name="fluent:alert-12-regular" width="30px" class="text-gray-950 bg-orange-500 rounded-full p-2"/></li>
<li>
<a href="{{ path('app_logout') }}">
<twig:ux:icon name="material-symbols:logout" width="25px" class="text-orange-500" />
</a>
</li>
<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

@@ -9,12 +9,12 @@
{% if show_cancel is defined or show_submit is defined %}
<div class="flex justify-end">
{% if show_cancel is defined %}
<button type="button" data-action="dialog#close" class="px-1 py-1 rounded-md self-end w-16 hover:bg-stone-100" autofocus>
<button type="button" data-action="dialog#close" class="secondary-btn" autofocus>
{{ cancel_text|default('Cancel') }}
</button>
{% endif %}
{% if show_submit is defined %}
<button type="button" {{ submit_action|raw }} class="px-1 py-1 rounded-md bg-orange-500 self-end text-white w-16 ml-2 hover:bg-orange-600" autofocus>
<button type="button" {{ submit_action|raw }} class="primary-btn" autofocus>
{{ submit_text|default('Submit') }}
</button>
{% endif %}
@@ -22,5 +22,5 @@
{% endif %}
</dialog>
<button type="button" data-action="dialog#open">{{ button_text|raw }}</button>
<button type="button" data-action="dialog#open" class="{{ button_class|default('') }}">{{ button_text|raw }}</button>
</div>

View File

@@ -16,32 +16,32 @@
ID
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="px-6 py-3 text-start text-xs font-medium uppercase">
Search Count
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="px-6 py-3 text-start text-xs font-medium uppercase">
Created at
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="px-6 py-3 text-start text-xs font-medium uppercase">
Last Search Date
</th>
<th scope="col"
class="hidden md:table-cell px-6 py-3 text-start text-xs font-medium uppercase">
class="px-6 py-3 text-start text-xs font-medium uppercase">
Type
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium uppercase">
Status
</th>
<th class="hidden md:table-cell"></th>
<th></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" isWidget="{{ this.isWidget }}" />
<twig:MonitorListRow :monitor="monitor" />
{% 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="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.searchCount }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.createdAt|date('m/d/Y h:i a') }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{{ monitor.lastSearch|date('m/d/Y h:i a') }}
</td>
<td class="hidden md:table-cell px-6 py-4 whitespace-nowrap text-sm text-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">
{% if monitor.monitorType == "tvshow" %}
<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="hidden md:table-cell px-6 py-4 flex flex-row align-middle justify-center">
<td class="px-6 py-4 flex flex-row align-middle justify-center">
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '18px', class: 'rounded-full align-middle text-red-600' }) %}
<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 id="navbar" {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950 animate__animated animate__slideInLeft animate__slow">
<nav {{ attributes }} {{ stimulus_controller('navbar') }} {{ stimulus_action('navbar', 'setActive')}} class="flex h-screen flex-col justify-between bg-cyan-950">
<div class="px-4 py-4 flex flex-col gap-12">
<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">
<h1 class="text-3xl font-extrabold text-orange-500 mb-3">Torsearch</h1>
<ul class="space-y-1">
<li>
<a href="{{ path('app_index') }}"
class="block rounded-lg

View File

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

View File

@@ -1,24 +1,15 @@
<div {{ attributes }} class="w-full max-w-sm min-w-[200px]">
<div class="relative">
<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"
<form action="{{ path('app_search') }}">
<input
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-0 transition
text-sm border border-orange-500 rounded-md pl-3 pr-28 py-2 transition
duration-300 ease focus:outline-none focus:border-orange-400 hover:border-orange-300
shadow-sm focus:shadow ts-search z-40"
shadow-sm focus:shadow"
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
@@ -27,7 +18,7 @@
text-white bg-green-600 text-sm
border border-green-500
backdrop-filter backdrop-blur-md bg-opacity-80 z-40
backdrop-filter backdrop-blur-md bg-opacity-80
"
type="submit"
>

View File

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

View File

@@ -15,12 +15,12 @@
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">
<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 episode['poster'] != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" />
<img class="w-64 rounded-lg" src="{{ episode['poster'] }}" />
{% else %}
<div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<div class="w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" />
</div>
{% endif %}

View File

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

View File

@@ -4,8 +4,8 @@
{% block h2 %}Dashboard{% endblock %}
{% block body %}
<div class="p-4 flex flex-col grow gap-4 z-10">
<div class="flex flex-col md:flex-row gap-4">
<div class="p-4 flex flex-col grow gap-4 z-30">
<div class="flex 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-col md:flex-row gap-4">
<div class="flex 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-col gap-4 md:flex-row md:justify-between w-full">
<twig:Card title="Popular Movies" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_movies %}
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"
@@ -32,7 +32,7 @@
/>
{% endfor %}
</twig:Card>
<twig:Card title="Popular TV Shows" contentClass="flex flex-col md:flex-row justify-between w-full">
<twig:Card title="Popular TV Shows" contentClass="flex flex-row justify-between w-full">
{% for movie in popular_tvshows %}
<twig:Poster imdbId="{{ movie.imdbId }}"
tmdbId="{{ movie.tmdbId }}"

View File

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

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-2 md:p-4 flex flex-col md:flex-row gap-6">
<div class="p-4 flex flex-row gap-6">
{% if results.media.poster != null %}
<img class="w-full md:w-40 rounded-lg" src="{{ results.media.poster }}" />
<img class="w-40 rounded-lg" src="{{ results.media.poster }}" />
{% else %}
<div class="w-full md:w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<div class="w-40 h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="24" name="hugeicons:loading-01" />
</div>
{% endif %}
@@ -115,10 +115,7 @@
<twig:Filter results="{{ results }}" filter="{{ filter }}" />
{% if "movies" == results.media.mediaType %}
<div class="results"
{{ stimulus_controller('movie_results', {title: results.media.title, tmdbId: results.media.tmdbId, imdbId: results.media.imdbId}) }}
data-movie-results-loading-icon-outlet=".loading-icon"
>
<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 %}
<twig:TvEpisodeList
@@ -130,20 +127,4 @@
</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,76 +1,66 @@
<table class="w-full max-w-[75vw] text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 flex-row flex-no-wrap {{ results.media.mediaType == "tvshows" ? "hidden" : "options-table" }}"
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400 {{ results.media.mediaType == "tvshows" ? "hidden" }}"
{{ stimulus_target(controller, "list") }}
>
<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">
<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">
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Size
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
Quality
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Resolution
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Codec
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Seeders
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Provider
</th>
<th scope="col"
class="px-4 py-4 leading-[20px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
Language
</th>
<th scope="col"
class="px-4 py-4 leading-[32px] font-medium text-gray-900 whitespace-nowrap dark:text-white">
Actions
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
</th>
</tr>
{% endfor %}
</thead>
<tbody class="flex-1 sm:flex-none">
<tbody>
{% for result in results.results %}
<tr class="bg-white dark:bg-slate-700 flex flex-col flex-no wrap r-tablerow border-b border-gray-500" data-provider="{{ result.provider }}" data-quality="{{ result.quality }}" data-languages="{{ result.languages|json_encode }}" {% if "tvshows" == results.media.mediaType %} data-season="{{ results.season }}"{% endif %}>
<td id="size" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
<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">
{{ result.size }}
</td>
<td id="quality" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.quality }}
</td>
<td id="resolution" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
<td id="resolution" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.resolution }}
</td>
<td id="codec" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
<td id="codec" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.codec }}
</td>
<td id="seeders" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
<td id="seeders" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.seeders }}
</td>
<td id="provider" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50 " data-provider="{{ result.provider }}">
<td id="provider" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50" data-provider="{{ result.provider }}">
{{ result.provider }}
</td>
<td id="language" class="px-4 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50 overflow-scroll" data-languages="{{ result.languages|json_encode }}">
<td id="language" class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50" data-languages="{{ result.languages|json_encode }}">
{{ result.languageFlags|raw }}
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-start mb:justify-end">
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50 flex flex-row gap-2 items-center justify-end">
<button class="download-btn p-1.5 bg-green-600 rounded-md text-gray-50"
{{ stimulus_controller('download_button', {
url: result.url,
title: results.media.title,
filename: result.filename,
mediaType: results.media.mediaType,
imdbId: results.media.imdbId ?? app.current_route_parameters.imdbId,
imdbId: results.media.imdbId,
episodeId: results|episode_id_from_results
}) }}
{{ stimulus_action('download_button', 'download', 'click') }}

View File

@@ -2,25 +2,23 @@
{% block title %}Preferences{% endblock %}
{% block h2 %}Preferences{% endblock %}
{% block action_buttons %}
<div {{ stimulus_controller('clear_cache') }}>
<twig:Modal heading="Hold on a sec!" button_text="Clear Cache" cancel_text="Nope" submit_text="Yep" show_cancel show_submit
button_class="px-1.5 py-1 my-2 text-white text-sm bg-blue-950 hover:bg-black/80 border-2 border-blue-500/90 rounded-md inline-block"
submit_action="{{ stimulus_action('clear_cache', 'clearAll', 'click') }}"
>
This will clear the TMDB, Torrentio, and application cache. Clearing the cache is safe, but may lead to
slower page loads and rate limits by Torrentio. Would you like to proceed?
</twig:Modal>
</div>
{% endblock %}
{% block body %}
<div class="p-4 flex flex-col md:flex-row gap-2">
<div class="p-4 flex 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') }}">
<label class="text-gray-50" for="quality">Quality</label>
<select class="p-1.5 rounded-md mb-2" name="quality" id="quality" value="{{ mediaPreferences['quality'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['quality'].getPreferenceValue() is null ? "selected" }}
>n/a</option>
{% for quality in qualities %}
<option class="text-gray-800"
value="{{ quality }}"
{{ quality == mediaPreferences['quality'].getPreferenceValue() ? "selected" }}
>{{ quality }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="resolution">Resolution</label>
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ mediaPreferences['resolution'].getPreferenceValue() }}">
<option class="text-gray-800"