Compare commits

..

13 Commits

46 changed files with 498 additions and 466 deletions

10
assets/bootstrap.js vendored
View File

@@ -1,10 +1,14 @@
import { startStimulusApp } from '@symfony/stimulus-bundle'; import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover' import Popover from '@stimulus-components/popover';
import Dialog from '@stimulus-components/dialog' import Dialog from '@stimulus-components/dialog';
import Dropdown from '@stimulus-components/dropdown' import Dropdown from '@stimulus-components/dropdown';
import 'animate.css';
import Brock from './components/brock.js';
const app = startStimulusApp(); const app = startStimulusApp();
// register any custom, 3rd party controllers here // register any custom, 3rd party controllers here
app.register('popover', Popover); app.register('popover', Popover);
app.register('dialog', Dialog); app.register('dialog', Dialog);
app.register('dropdown', Dropdown); app.register('dropdown', Dropdown);
customElements.define('brock-app', Brock);

View File

@@ -0,0 +1,25 @@
export default class Brock extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.render();
}
render() {
this.innerHTML = `
Yo, yo, yo! Waddup ${this.name}, doe, it's Brocky fresh!
`;
}
// attribute change
attributeChangedCallback(property, oldValue, newValue) {
if (oldValue === newValue) return;
this[ property ] = newValue;
this.render();
}
static get observedAttributes() {
return ['name'];
}
}

View File

@@ -24,6 +24,7 @@ export default class extends Controller {
toggle() { toggle() {
this.element.parentElement.classList.toggle('hidden'); this.element.parentElement.classList.toggle('hidden');
this.element.classList.toggle('animate__slideInLeft');
this.element.classList.toggle('fixed'); this.element.classList.toggle('fixed');
this.element.classList.toggle('z-20'); this.element.classList.toggle('z-20');
} }

View File

@@ -14,6 +14,11 @@ export default class extends Controller {
this.component.on('render:finished', (component) => { this.component.on('render:finished', (component) => {
console.log(component); console.log(component);
}); });
if (window.location.hash) {
let targetElement = document.querySelector(window.location.hash);
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
targetElement.classList.add('animate__animated', 'animate__pulse', 'animate__faster');
}
} }
setSeason(season) { setSeason(season) {
@@ -25,6 +30,7 @@ export default class extends Controller {
paginate(event) { paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove()); this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('episodeNumber', null);
this.component.action('paginate', {page: event.params.page}); this.component.action('paginate', {page: event.params.page});
this.component.render(); this.component.render();
} }

View File

@@ -65,7 +65,7 @@ dialog[data-dialog-target="dialog"][closing] {
} }
.text-input { .text-input {
@apply bg-gray-50 text-gray-50 p-1 bg-transparent border-b-2 border-orange-400 @apply bg-gray-50 text-gray-50 px-2 py-1 bg-transparent border-b-2 border-orange-400
} }
.submit-button { .submit-button {

View File

@@ -10,9 +10,9 @@ pwa:
theme_color: "#083344" theme_color: "#083344"
description: Torsearch provides a simple and intuitive way to manage your personal media library. description: Torsearch provides a simple and intuitive way to manage your personal media library.
icons: icons:
- src: "icon.png" - src: "/icon.png"
sizes: [ 192 ] sizes: [ 192 ]
- src: "icon.png" - src: "/icon.png"
sizes: [ 192 ] sizes: [ 192 ]
purpose: maskable purpose: maskable
categories: categories:

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250724042107 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
ALTER TABLE preference_option DROP FOREIGN KEY FK_607C52FD81022C0
SQL);
$this->addSql(<<<'SQL'
DROP TABLE preference_option
SQL);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
CREATE TABLE preference_option (id INT AUTO_INCREMENT NOT NULL, preference_id VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, name VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, enabled TINYINT(1) NOT NULL, INDEX IDX_607C52FD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = ''
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE preference_option ADD CONSTRAINT FK_607C52FD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id)
SQL);
}
}

View File

@@ -20,17 +20,15 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class SeedDatabaseCommand extends Command class SeedDatabaseCommand extends Command
{ {
private PreferencesRepository $preferenceRepository; private PreferencesRepository $preferenceRepository;
private PreferenceOptionRepository $preferenceOptionRepository;
private UserRepository $userRepository; private UserRepository $userRepository;
public function __construct( public function __construct(
PreferencesRepository $preferenceRepository, PreferencesRepository $preferenceRepository,
PreferenceOptionRepository $preferenceOptionRepository,
UserRepository $userRepository, UserRepository $userRepository,
) { ) {
parent::__construct(); parent::__construct();
$this->preferenceRepository = $preferenceRepository; $this->preferenceRepository = $preferenceRepository;
$this->preferenceOptionRepository = $preferenceOptionRepository;
$this->userRepository = $userRepository; $this->userRepository = $userRepository;
} }
@@ -39,7 +37,6 @@ class SeedDatabaseCommand extends Command
$io = new SymfonyStyle($input, $output); $io = new SymfonyStyle($input, $output);
$this->seedPreferences($io); $this->seedPreferences($io);
$this->seedPreferenceOptions($io);
$this->updateUserPreferences($io); $this->updateUserPreferences($io);
return Command::SUCCESS; return Command::SUCCESS;
@@ -140,72 +137,4 @@ class SeedDatabaseCommand extends Command
], ],
]; ];
} }
private function seedPreferenceOptions(SymfonyStyle $io)
{
$io->info('[SeedDatabaseCommand] > Seeding preference options...');
$options = $this->getPreferenceOptions();
foreach ($options as $option) {
if ($this->preferenceOptionRepository->findBy([
'preference' => $option['preference_id'],
'name' => $option['name'],
'value' => $option['value'],
'enabled' => $option['enabled'],
])) {
continue;
}
$this->preferenceOptionRepository->getEntityManager()->persist(
(new \App\User\Framework\Entity\PreferenceOption())
->setPreference($this->preferenceRepository->find($option['preference_id']))
->setName($option['name'])
->setValue($option['value'])
->setEnabled($option['enabled'])
);
}
$this->preferenceOptionRepository->getEntityManager()->flush();
}
private function getPreferenceOptions(): array
{
return [
[
'preference_id' => 'resolution',
'name' => '720p',
'value' => '720p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '1080p',
'value' => '1080p',
'enabled' => true
],
[
'preference_id' => 'resolution',
'name' => '2160p',
'value' => '2160p',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => '-',
'value' => '-',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h264',
'value' => 'h264',
'enabled' => true
],
[
'preference_id' => 'codec',
'name' => 'h265/HEVC',
'value' => 'h265',
'enabled' => true
]
];
}
} }

View File

@@ -11,9 +11,9 @@ readonly class MediaFileDto
public string $size, public string $size,
) {} ) {}
public static function fromSplFileInfo(\SplFileInfo $fileInfo): self public static function fromSplFileInfo(\SplFileInfo|false $fileInfo): self|false
{ {
return new static( return false === $fileInfo ? false : new static(
path: $fileInfo->getRealPath(), path: $fileInfo->getRealPath(),
filename: $fileInfo->getFilename(), filename: $fileInfo->getFilename(),
extension: $fileInfo->getExtension(), extension: $fileInfo->getExtension(),

View File

@@ -11,5 +11,6 @@ class GetMediaInfoCommand implements CommandInterface
public string $imdbId, public string $imdbId,
public string $mediaType, public string $mediaType,
public ?int $season = null, public ?int $season = null,
public ?int $episode = null,
) {} ) {}
} }

View File

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

View File

@@ -19,6 +19,9 @@ class GetMediaInfoInput implements InputInterface
#[SourceRoute('season', nullify: true)] #[SourceRoute('season', nullify: true)]
public ?int $season, public ?int $season,
#[SourceRoute('episode', nullify: true)]
public ?int $episode,
) {} ) {}
public function toCommand(): CommandInterface public function toCommand(): CommandInterface
@@ -26,6 +29,10 @@ class GetMediaInfoInput implements InputInterface
if ("tvshows" === $this->mediaType && null === $this->season) { if ("tvshows" === $this->mediaType && null === $this->season) {
$this->season = 1; $this->season = 1;
} }
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season);
if ("tvshows" === $this->mediaType && null === $this->episode) {
$this->episode = 1;
}
return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season, $this->episode);
} }
} }

View File

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

View File

@@ -33,7 +33,7 @@ final class WebController extends AbstractController
]); ]);
} }
#[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')] #[Route('/result/{mediaType}/{imdbId}/{season}/{episode?}', name: 'app_search_result')]
public function result( public function result(
GetMediaInfoInput $input, GetMediaInfoInput $input,
?int $season = null, ?int $season = null,

View File

@@ -3,6 +3,7 @@
namespace App\Torrentio\Action\Handler; namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand; use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -28,7 +29,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
return new GetTvShowOptionsResult( return new GetTvShowOptionsResult(
media: $media, media: $media,
file: $file, file: MediaFileDto::fromSplFileInfo($file),
season: $command->season, season: $command->season,
episode: $command->episode, episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults( results: $this->torrentio->fetchEpisodeResults(

View File

@@ -2,15 +2,15 @@
namespace App\Torrentio\Action\Result; namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Finder\SplFileInfo;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $media, public TmdbResult $media,
public bool|SplFileInfo $file, public MediaFileDto|false $file,
public string $season, public string $season,
public string $episode, public string $episode,
public array $results public array $results

View File

@@ -13,6 +13,7 @@ use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
@@ -26,6 +27,7 @@ final class WebController extends AbstractController
private readonly Broadcaster $broadcaster, private readonly Broadcaster $broadcaster,
) {} ) {}
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')] #[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
{ {
@@ -35,18 +37,18 @@ final class WebController extends AbstractController
$input->imdbId $input->imdbId
); );
return $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) { $results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand()); return $this->getMovieOptionsHandler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
}); });
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
} }
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')] #[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
@@ -61,18 +63,18 @@ final class WebController extends AbstractController
); );
try { try {
return $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) { $results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0)); $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand()); return $this->getTvShowOptionsHandler->handle($input->toCommand());
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
}); });
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
}
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
} catch (TorrentioRateLimitException $exception) { } catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning'); $this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig', return $this->render('bare.html.twig',

View File

@@ -3,14 +3,20 @@
namespace App\Twig\Components; namespace App\Twig\Components;
use Aimeos\Map; use Aimeos\Map;
use App\User\Database\CodecList;
use App\User\Database\QualityList; use App\User\Database\QualityList;
use App\User\Database\ResolutionList;
use App\User\Dto\PreferenceOptions;
use App\User\Dto\PreferenceOptionsFactory;
use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent] #[AsLiveComponent]
final class Filter final class Filter extends AbstractController
{ {
use DefaultActionTrait; use DefaultActionTrait;
@@ -21,15 +27,20 @@ final class Filter
public array $reverseMappedQualities = []; public array $reverseMappedQualities = [];
public function __construct( public function __construct(
private readonly PreferencesRepository $preferencesRepository,
private readonly Security $security, private readonly Security $security,
) { ) {
$this->preferences = Map::from($this->preferencesRepository->findEnabled()) $this->preferences = (array) PreferenceOptionsFactory::createSelectOptions();
->rekey(fn($element) => $element->getId()) $this->userPreferences = (array) UserPreferencesFactory::createFromUser($security->getUser());
->map(fn($element) => $element->getPreferenceOptions()->toArray())
->toArray();
$this->userPreferences = Map::from($this->security->getUser()->getUserPreferenceValues())
->toArray();
$this->reverseMappedQualities = QualityList::getAsReverseMap(); $this->reverseMappedQualities = QualityList::getAsReverseMap();
} }
public function getResolutionOptions()
{
return ResolutionList::asSelectOptions();
}
public function getCodecOptions()
{
return CodecList::asSelectOptions();
}
} }

View File

@@ -6,11 +6,13 @@ use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler; use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\TvEpisodePaginator; use App\Search\TvEpisodePaginator;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\DefaultActionTrait;
#[AsLiveComponent] #[AsLiveComponent]
final class TvEpisodeList final class TvEpisodeList
{ {
use DefaultActionTrait; use DefaultActionTrait;
use PaginateTrait; use PaginateTrait;
@@ -27,6 +29,12 @@ final class TvEpisodeList
#[LiveProp(writable: true)] #[LiveProp(writable: true)]
public int $season = 1; public int $season = 1;
#[LiveProp(writable: true)]
public int $reloadCount = 0;
#[LiveProp(writable: true)]
public ?int $episodeNumber = null;
public function __construct( public function __construct(
private GetMediaInfoHandler $getMediaInfoHandler, private GetMediaInfoHandler $getMediaInfoHandler,
) {} ) {}
@@ -34,6 +42,14 @@ final class TvEpisodeList
public function getEpisodes() public function getEpisodes()
{ {
$results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season)); $results = $this->getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season));
if (null !== $this->episodeNumber) {
$this->pageNumber = ceil($this->episodeNumber / $this->perPage);
$this->episodeNumber = null;
}
$this->reloadCount++;
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage); return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
} }

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Twig\Dto;
class EpisodeIdDto
{
public function __construct(
public string $season,
public string $episode,
) {}
public function asEpisodeId(): string
{
return "S". str_pad($this->season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($this->episode, 2, "0", STR_PAD_LEFT);
}
public function __toString(): string
{
if ("" !== $this->season && "" !== $this->episode) {
return $this->asEpisodeId();
}
return "";
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Twig\Extensions;
use App\Base\Service\MediaFiles; use App\Base\Service\MediaFiles;
use App\Torrentio\Action\Result\GetTvShowOptionsResult; use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize; use ChrisUllyott\FileSize;
use Twig\Attribute\AsTwigFilter; use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction; use Twig\Attribute\AsTwigFunction;
@@ -63,4 +64,42 @@ class UtilExtension
return "S". str_pad($season, 2, "0", STR_PAD_LEFT) . return "S". str_pad($season, 2, "0", STR_PAD_LEFT) .
"E". str_pad($episode, 2, "0", STR_PAD_LEFT); "E". str_pad($episode, 2, "0", STR_PAD_LEFT);
} }
#[AsTwigFunction('episode_anchor')]
public function episodeAnchor($season, $episode): ?string
{
return "episode_" . $season . "_" . $episode;
}
#[AsTwigFunction('extract_from_episode_id')]
public function extractFromEpisodeId(?string $episodeId): ?EpisodeIdDto
{
if (null === $episodeId) {
return new EpisodeIdDto("", "");
}
// Capture season
$seasonMatch = [];
preg_match('/[sS]\d\d/', $episodeId, $seasonMatch);
if (empty($seasonMatch)) {
$season = "";
} else {
$season = str_replace(['S', 's'], '', $seasonMatch[0]);
}
// Capture episode
$episodeMatch = [];
preg_match('/[eE]\d\d/', $episodeId, $episodeMatch);
if (empty($episodeMatch)) {
$episode = "";
} else {
$episode = str_replace(['E', 'e'], '', $episodeMatch[0]);
}
if (null === $season && null === $episode) {
return new EpisodeIdDto("", "");
}
return new EpisodeIdDto($season, $episode);
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\User\Action\Command; namespace App\User\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
use Symfony\Component\Form\FormInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */ /** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserMediaPreferencesCommand implements CommandInterface class SaveUserMediaPreferencesCommand implements CommandInterface
@@ -14,4 +15,15 @@ class SaveUserMediaPreferencesCommand implements CommandInterface
public string $language, public string $language,
public string $provider, public string $provider,
) {} ) {}
public static function fromUserMediaPreferencesForm(FormInterface $form): self
{
return new static(
resolution: $form->get('resolution')->getData(),
codec: $form->get('codec')->getData(),
quality: $form->get('quality')->getData(),
language: $form->get('language')->getData(),
provider: $form->get('provider')->getData(),
);
}
} }

View File

@@ -0,0 +1,25 @@
<?php
namespace App\User\Database;
class CodecList
{
public static $codecs = [
'h264',
'h265/HEVC',
];
public static function getCodecs()
{
return self::$codecs;
}
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$codecs as $codec) {
$result[$codec] = $codec;
}
return $result;
}
}

View File

@@ -102,7 +102,7 @@ class QualityList
public static function asSelectOptions(): array public static function asSelectOptions(): array
{ {
$result = []; $result = ['n/a' => null];
foreach (array_keys(static::$qualities) as $quality) { foreach (array_keys(static::$qualities) as $quality) {
$result[$quality] = $quality; $result[$quality] = $quality;
} }

View File

@@ -0,0 +1,27 @@
<?php
namespace App\User\Database;
class ResolutionList
{
public static $resolutions = [
'480p',
'720p',
'1080p',
'2160p',
];
public static function getResolutions()
{
return self::$resolutions;
}
public static function asSelectOptions(): array
{
$result = [];
foreach (static::$resolutions as $resolution) {
$result[$resolution] = $resolution;
}
return $result;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Dto;
class PreferenceOptions
{
public function __construct(
public readonly array $resolutions,
public readonly array $codecs,
public readonly array $languages,
public readonly array $providers,
public readonly array $qualities,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\User\Dto;
use App\User\Database\CodecList;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Database\ResolutionList;
class PreferenceOptionsFactory
{
public static function createSelectOptions(): PreferenceOptions
{
return new PreferenceOptions(
resolutions: ResolutionList::asSelectOptions(),
codecs: CodecList::asSelectOptions(),
languages: CountryLanguages::asSelectOptions(),
providers: ProviderList::asSelectOptions(),
qualities: QualityList::asSelectOptions(),
);
}
}

View File

@@ -12,8 +12,8 @@ class UserPreferencesFactory
public static function createFromUser(UserInterface $user): UserPreferences public static function createFromUser(UserInterface $user): UserPreferences
{ {
return new UserPreferences( return new UserPreferences(
resolution: self::getNestedValue($user, 'resolution'), resolution: self::getValue($user, 'resolution'),
codec: self::getNestedValue($user, 'codec'), codec: self::getValue($user, 'codec'),
language: self::getValue($user, 'language'), language: self::getValue($user, 'language'),
provider: self::getValue($user, 'provider'), provider: self::getValue($user, 'provider'),
quality: self::getValue($user, 'quality'), quality: self::getValue($user, 'quality'),
@@ -29,19 +29,4 @@ class UserPreferencesFactory
} }
return $value; return $value;
} }
/** @param User $user */
private static function getNestedValue(UserInterface $user, string $preferenceId): ?string
{
$preference = $user->getUserPreference($preferenceId);
if (null === $preference) {
return null;
}
return $preference->getPreference()
->getPreferenceOptions()
->filter(fn (PreferenceOption $option) => (string) $option->getId() === $preference->getPreferenceValue())
->first()
->getValue()
;
}
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web; namespace App\User\Framework\Controller\Web;
use App\Base\Service\Broadcaster; use App\Base\Service\Broadcaster;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler; use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler; use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Input\SaveUserDownloadPreferencesInput; use App\User\Action\Input\SaveUserDownloadPreferencesInput;
@@ -14,8 +15,10 @@ use App\User\Database\ProviderList;
use App\User\Database\QualityList; use App\User\Database\QualityList;
use App\User\Dto\UserPreferencesFactory; use App\User\Dto\UserPreferencesFactory;
use App\User\Framework\Form\GettingStartedFilterForm; use App\User\Framework\Form\GettingStartedFilterForm;
use App\User\Framework\Form\UserMediaPreferencesForm;
use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\PreferencesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -29,53 +32,43 @@ class PreferencesController extends AbstractController
#[Route('/user/preferences', 'app_user_preferences', methods: ['GET'])] #[Route('/user/preferences', 'app_user_preferences', methods: ['GET'])]
public function mediaPreferences(): Response public function mediaPreferences(): Response
{ {
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences(); $downloadPreferences = $this->getUser()->getDownloadPreferences();
$languages = CountryLanguages::$languages; $formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
sort($languages); $form = $this->createForm(UserMediaPreferencesForm::class, $formData);
return $this->render( return $this->render(
'user/preferences.html.twig', 'user/preferences.html.twig',
[ [
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class, (array) UserPreferencesFactory::createFromUser($this->getUser())), 'preferences_form' => $form,
] ]
); );
} }
#[Route('/user/preferences/media', 'app_save_media_preferences', methods: ['POST'])] #[Route('/user/preferences/media', 'app_user_media_preferences_submit', methods: ['POST'])]
public function saveMediaPreferences( public function mediaPreferencesSubmit(
SaveUserMediaPreferencesInput $input, Request $request,
SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler, SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler
): Response ): Response
{ {
$saveUserMediaPreferencesHandler->handle($input->toCommand());
$mediaPreferences = $this->getUser()->getMediaPreferences();
$downloadPreferences = $this->getUser()->getDownloadPreferences(); $downloadPreferences = $this->getUser()->getDownloadPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$languages = CountryLanguages::$languages; $form->handleRequest($request);
sort($languages);
$this->broadcaster->alert( if ($form->isSubmitted() && $form->isValid()) {
title: 'Success', $saveUserMediaPreferencesHandler->handle(
message: 'Your media preferences have been saved.' SaveUserMediaPreferencesCommand::fromUserMediaPreferencesForm($form)
); );
$this->broadcaster->alert('Success', 'Your media preferences have been saved.');
}
return $this->render( return $this->render(
'user/preferences.html.twig', 'user/preferences.html.twig',
[ [
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::$providers,
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
'filterForm' => $this->createForm(GettingStartedFilterForm::class ?? null), 'preferences_form' => $form,
] ]
); );
} }
@@ -86,11 +79,11 @@ class PreferencesController extends AbstractController
SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler, SaveUserDownloadPreferencesHandler $saveUserDownloadPreferencesHandler,
): Response ): Response
{ {
$downloadPreferences = $saveUserDownloadPreferencesHandler->handle($input->toCommand())->downloadPreferences; $downloadPreferences = $this->getUser()->getDownloadPreferences();
$mediaPreferences = $this->getUser()->getMediaPreferences(); $formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$languages = CountryLanguages::$languages; $saveUserDownloadPreferencesHandler->handle($input->toCommand());
sort($languages);
$this->broadcaster->alert( $this->broadcaster->alert(
title: 'Success', title: 'Success',
@@ -100,12 +93,8 @@ class PreferencesController extends AbstractController
return $this->render( return $this->render(
'user/preferences.html.twig', 'user/preferences.html.twig',
[ [
'preferences' => $this->preferencesRepository->findEnabled(),
'languages' => $languages,
'providers' => ProviderList::getProviders(),
'qualities' => QualityList::getBaseQualities(),
'mediaPreferences' => $mediaPreferences,
'downloadPreferences' => $downloadPreferences, 'downloadPreferences' => $downloadPreferences,
'preferences_form' => $form,
] ]
); );
} }

View File

@@ -26,17 +26,6 @@ class Preference
#[ORM\Column] #[ORM\Column]
private ?bool $enabled = null; private ?bool $enabled = null;
/**
* @var Collection<int, PreferenceOption>
*/
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
private Collection $preferenceOptions;
public function __construct()
{
$this->preferenceOptions = new ArrayCollection();
}
public function getId(): ?string public function getId(): ?string
{ {
return $this->id; return $this->id;
@@ -94,34 +83,4 @@ class Preference
return $this; return $this;
} }
/**
* @return Collection<int, PreferenceOption>
*/
public function getPreferenceOptions(): Collection
{
return $this->preferenceOptions;
}
public function addPreferenceOption(PreferenceOption $preferenceOption): static
{
if (!$this->preferenceOptions->contains($preferenceOption)) {
$this->preferenceOptions->add($preferenceOption);
$preferenceOption->setPreference($this);
}
return $this;
}
public function removePreferenceOption(PreferenceOption $preferenceOption): static
{
if ($this->preferenceOptions->removeElement($preferenceOption)) {
// set the owning side to null (unless already changed)
if ($preferenceOption->getPreference() === $this) {
$preferenceOption->setPreference(null);
}
}
return $this;
}
} }

View File

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

View File

@@ -215,11 +215,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
if (in_array($userPreference->getPreference()->getId(), ['language', 'provider', 'quality'])) { if (in_array($userPreference->getPreference()->getId(), ['language', 'provider', 'quality'])) {
return $userPreference->getPreferenceValue(); return $userPreference->getPreferenceValue();
} }
foreach ($userPreference->getPreference()->getPreferenceOptions() as $preferenceOption) {
if ($preferenceOption->getId() === (int) $userPreference->getPreferenceValue()) {
return $preferenceOption->getValue();
}
}
return null; return null;
}) })
->toArray(); ->toArray();

View File

@@ -2,11 +2,11 @@
namespace App\User\Framework\Form; namespace App\User\Framework\Form;
use Aimeos\Map; use App\User\Database\CodecList;
use App\User\Database\CountryLanguages; use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList; use App\User\Database\ProviderList;
use App\User\Database\QualityList; use App\User\Database\QualityList;
use App\User\Framework\Repository\PreferenceOptionRepository; use App\User\Database\ResolutionList;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
@@ -14,17 +14,14 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class GettingStartedFilterForm extends AbstractType class GettingStartedFilterForm extends AbstractType
{ {
public function __construct(
private readonly PreferenceOptionRepository $preferenceOptionRepository,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions()); $this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions()); $this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions()); $this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
$this->addChoiceField($builder, 'resolution', $this->getPreferenceChoices('resolution')); $this->addChoiceField($builder, 'resolution', ResolutionList::asSelectOptions());
$this->addChoiceField($builder, 'codec', $this->getPreferenceChoices('codec')); $this->addChoiceField($builder, 'codec', CodecList::asSelectOptions());;
} }
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
@@ -42,16 +39,6 @@ class GettingStartedFilterForm extends AbstractType
$resolver->setDefaults([]); $resolver->setDefaults([]);
} }
private function getPreferenceChoices(string $preference): array
{
$options = $this->preferenceOptionRepository->findBy(['preference' => $preference]);
$result = [];
foreach ($options as $item) {
$result[$item->getName()] = $item->getId();
}
return $result;
}
private function addDefaultChoice(array $choices): iterable private function addDefaultChoice(array $choices): iterable
{ {
return ['n/a' => null] + $choices; return ['n/a' => null] + $choices;

View File

@@ -0,0 +1,56 @@
<?php
namespace App\User\Framework\Form;
use Aimeos\Map;
use App\User\Database\CodecList;
use App\User\Database\CountryLanguages;
use App\User\Database\ProviderList;
use App\User\Database\QualityList;
use App\User\Database\ResolutionList;
use App\User\Framework\Repository\PreferenceOptionRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGenerator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class UserMediaPreferencesForm extends AbstractType
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addChoiceField($builder, 'language', CountryLanguages::asSelectOptions());
$this->addChoiceField($builder, 'quality', QualityList::asSelectOptions());
$this->addChoiceField($builder, 'provider', ProviderList::asSelectOptions());
$this->addChoiceField($builder, 'resolution', ResolutionList::asSelectOptions());
$this->addChoiceField($builder, 'codec', CodecList::asSelectOptions());
}
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices): void
{
$question = [
'attr' => ['class' => 'w-64 text-input mb-4'],
'label_attr' => ['class' => 'w-64 text-white block font-semibold mb-2'],
'choices' => $this->addDefaultChoice($choices),
'required' => false,
];
$builder->add($fieldName, ChoiceType::class, $question);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'action' => $this->urlGenerator->generate('app_user_media_preferences_submit'),
]);
}
private function addDefaultChoice(array $choices): iterable
{
return ['n/a' => ''] + $choices;
}
}

View File

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

View File

@@ -10,6 +10,8 @@ module.exports = {
"flex-row", "flex-row",
"p-2", "p-2",
"p-4", "p-4",
"w-32",
"w-64",
"bg-blue-300", "bg-blue-300",
"bg-orange-300", "bg-orange-300",
"bg-fuchsia-300", "bg-fuchsia-300",
@@ -34,6 +36,9 @@ module.exports = {
"rounded-sm", "rounded-sm",
"rounded-md", "rounded-md",
"r-tablecell", "r-tablecell",
"animate__animated",
"animate__slideInLeft",
"animate__animateFaster"
], ],
theme: { theme: {
extend: { extend: {

View File

@@ -1,6 +1,6 @@
<li {{ attributes }} id="alert_{{ alert_id }}" <li {{ attributes }} id="alert_{{ alert_id }}"
class="alert alert-{{ type|default('success') }}" class="alert alert-{{ type|default('success') }}"
role="alert" role="alert"
> >
<div class="flex items-center"> <div class="flex items-center">
<svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"> <svg class="shrink-0 w-4 h-4 me-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
@@ -9,7 +9,7 @@
<span class="sr-only">Info</span> <span class="sr-only">Info</span>
<h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3> <h3 class="text-lg font-medium font-bold">{{ title|default('') }}</h3>
</div> </div>
<div class="mt-2 text-sm w-[350px] font-bold"> <div class="mt-2 text-sm w-[300px] font-bold overflow-hidden text-wrap">
{{ message }} {{ message }}
</div> </div>
</li> </li>

View File

@@ -1,8 +1,15 @@
<tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}"> <tr{{ attributes }} class="hover:bg-gray-200" id="ad_download_{{ download.id }}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-stone-800 truncate">
<a href="{{ path('app_search_result', {imdbId: download.imdbId, mediaType: download.mediaType}) }}" {% if download.mediaType == "movies" %}
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate" {% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType} %}
> {% set route = path('app_search_result', routeParams) %}
{% else %}
{% set episodeIdDto = extract_from_episode_id(download.episodeId) %}
{% set routeParams = {imdbId: download.imdbId, mediaType: download.mediaType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
{% endif %}
<a href="{{ route }}"
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
{{ download.title }} {{ download.title }}
</a> </a>
@@ -19,19 +26,22 @@
{{ download.mediaType }} {{ download.mediaType }}
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm align-middle text-gray-800 dark:text-gray-50"> <td class="whitespace-nowrap gap-2 text-sm align-middle text-gray-800 dark:text-gray-50">
{% if download.progress < 100 %} {% if download.progress < 100 %}
<div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-full h-6 align-middle overflow-hidden"> <div class="flex flex-row items-center justify-center">
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10" <div id="download_progress_{{ download.id }}" class="border-2 border-green-600 rounded-md text-center w-16 h-6 align-middle overflow-hidden">
style="width:{{ download.progress }}%"> <div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
style="width:{{ download.progress }}%">
</div>
</div> </div>
<div class="text-black text-center" style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">{{ download.progress }}%</div> <div class="text-black font-bold text-center z-40 ml-[-42px]">{{ download.progress }}%</div>
</div> </div>
{% else %} {% else %}
<twig:StatusBadge color="green" status="Complete" /> <twig:StatusBadge color="green" status="Complete" />
{% endif %} {% endif %}
</td> </td>
<td id="hidden md:table-cell action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center"> <td id="hidden md:table-cell action_buttons_{{ download.id }}" class="pl-2 pr-4 py-4 flex flex-row items-center justify-end">
{% if download.status == 'In Progress' and download.progress < 100 %} {% if download.status == 'In Progress' and download.progress < 100 %}
<button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}> <button id="pause_{{ download.id }}" class="text-orange-500 hover:text-orange-600 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}>
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" /> <twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />

View File

@@ -15,10 +15,10 @@
value="{{ app.user.userPreferenceValues["resolution"] }}" value="{{ app.user.userPreferenceValues["resolution"] }}"
> >
<option value="">n/a</option> <option value="">n/a</option>
{% for option in this.preferences['resolution'] %} {% for name, value in this.resolutionOptions %}
<option value="{{ option.value }}" <option value="{{ value }}"
{{ option.value == this.userPreferences['resolution'] ? 'selected' }} {{ value == this.userPreferences['resolution'] ? 'selected' }}
>{{ option.name }}</option> >{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@@ -26,10 +26,10 @@
Codec Codec
<select id="codec" data-result-filter-target="codec" class="px-1 py-0.5 bg-stone-100 text-sm text-gray-800 rounded-md"> <select id="codec" data-result-filter-target="codec" class="px-1 py-0.5 bg-stone-100 text-sm text-gray-800 rounded-md">
<option value="">n/a</option> <option value="">n/a</option>
{% for option in this.preferences['codec'] %} {% for name, value in this.codecOptions %}
<option value="{{ option.value }}" <option value="{{ value }}"
{{ option.value == this.userPreferences['codec'] ? 'selected' }} {{ value == this.userPreferences['codec'] ? 'selected' }}
>{{ option.name }}</option> >{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
@@ -73,18 +73,15 @@
{{ stimulus_action('result_filter', 'setSeason', 'change') }} {{ stimulus_action('result_filter', 'setSeason', 'change') }}
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }} {{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
> >
<option selected value="1">1</option> {% for season in range(1, results.media.episodes|length) %}
{% for season in range(2, results.media.episodes|length) %} <option value="{{ season }}"
<option value="{{ season }}">{{ season }}</option> {% if results.season == season %}
selected="selected"
{% endif %}
>{{ season }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
{# <label for="episodeNumber">#}
{# Episode#}
{# <select id="episodeNumber" name="episodeNumber" data-result-filter-target="episode" class="px-1 py-0.5 bg-stone-100 text-gray-800 rounded-sm">#}
{# <option selected value="">n/a</option>#}
{# </select>#}
{# </label>#}
{% endif %} {% endif %}
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }} <span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
class="loading-icon"> class="loading-icon">

View File

@@ -26,7 +26,7 @@
</div> </div>
</div> </div>
</div> </div>
<div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="fixed z-40 top-10 right-10"> <div {{ turbo_stream_listen(app.session.get('mercure_alert_topic')) }} class="fixed z-40 top-4 right-3 md:top-10 md:right-10">
<div class="z-40"> <div class="z-40">
<ul id="alert_list" class="flex flex-col gap-2"> <ul id="alert_list" class="flex flex-col gap-2">
{% for message in app.flashes('warning') %} {% for message in app.flashes('warning') %}

View File

@@ -3,7 +3,19 @@
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}" <a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
class="mr-1 hover:underline rounded-md" class="mr-1 hover:underline rounded-md"
> >
{{ monitor.title }}
{% if monitor.monitorType == "movies" %}
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType} %}
{% set route = path('app_search_result', routeParams) %}
{% else %}
{% set episodeIdDto = extract_from_episode_id(monitor|monitor_media_id) %}
{% set routeParams = {imdbId: monitor.imdbId, mediaType: monitor.monitorType, season: episodeIdDto.season, episode: episodeIdDto.episode} %}
{% set route = path('app_search_result', routeParams) ~ "#" ~ episode_anchor(episodeIdDto.season, episodeIdDto.episode) %}
{% endif %}
<a href="{{ route }}"
class="mr-1 hover:underline rounded-md max-w-[10ch] md:max-w-[unset] truncate">
{{ monitor.title }}
</a>
</a> </a>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">

View File

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

View File

@@ -2,8 +2,8 @@
class="episode-list flex flex-col gap-4" class="episode-list flex flex-col gap-4"
> >
<div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4"> <div data-live-id="{{ uniqid() }}" class="episode-container flex flex-col gap-4">
{% for episode in this.episodes.items %} {% for episode in this.getEpisodes().items %}
<div id="episode_{{ episode['season_number'] }}_{{ episode['episode_number'] }}" class="results" <div id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results"
data-tv-results-loading-icon-outlet=".loading-icon" data-tv-results-loading-icon-outlet=".loading-icon"
data-download-button-outlet=".download-btn" data-download-button-outlet=".download-btn"
{{ stimulus_controller('tv_results', { {{ stimulus_controller('tv_results', {

View File

@@ -0,0 +1,17 @@
<div{{ attributes }}>
<label class="text-gray-50" for="quality">{{ label }}</label>
<select class="p-1.5 rounded-md mb-2" name="quality" id="quality" value="{{ value }}">
{% if true == show_na %}
<option class="text-gray-800"
value=""
{{ value is null ? "selected" }}
>n/a</option>
{% endif %}
{% for option in options %}
<option class="text-gray-800"
value="{{ option.value }}"
{{ quality == option.value ? "selected" }}
>{{ option.label }}</option>
{% endfor %}
</select>
</div>

View File

@@ -96,7 +96,7 @@
<twig:TvEpisodeList <twig:TvEpisodeList
results="results" results="results"
:imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1" :imdbId="results.media.imdbId" :season="results.season" :perPage="20" :pageNumber="1"
:tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :tmdbId="results.media.tmdbId" :title="results.media.title" loading="defer" :episodeNumber="results.episode"
/> />
{% endif %} {% endif %}
</twig:Card> </twig:Card>

View File

@@ -5,84 +5,19 @@
{% block body %} {% block body %}
<div class="p-4 flex flex-col md:flex-row gap-2"> <div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Media Preferences" class="w-full"> <twig:Card title="Media Preferences" class="w-full">
<p class="text-gray-50 mb-2">Define a filter to be pre-applied to your download options.</p> <p class="text-gray-50 mb-4">Define a filter to be pre-applied to your download options.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_save_media_preferences') }}"> {{ form_start(preferences_form) }}
<label class="text-gray-50" for="quality">Quality</label> {{ form_row(preferences_form.language) }}
<select class="p-1.5 rounded-md mb-2" name="quality" id="quality" value="{{ mediaPreferences['quality'].getPreferenceValue() }}"> {{ form_row(preferences_form.quality) }}
<option class="text-gray-800" {{ form_row(preferences_form.provider) }}
value="" {{ form_row(preferences_form.resolution) }}
{{ mediaPreferences['quality'].getPreferenceValue() is null ? "selected" }} {{ form_row(preferences_form.codec) }}
>n/a</option> <button class="submit-button">Save</button>
{% for quality in qualities %} {{ form_end(preferences_form) }}
<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"
value=""
{{ mediaPreferences['resolution'] is null ? "selected" }}
>n/a</option>
{% for pref in mediaPreferences['resolution'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == mediaPreferences['resolution'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="codec">Codec</label>
<select class="p-1.5 rounded-md mb-2" name="codec" id="codec" value="{{ mediaPreferences['codec'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['codec'].getPreferenceValue() is null ? "selected" }}
>n/a</option>
{% for pref in mediaPreferences['codec'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == mediaPreferences['codec'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="provider">Provider</label>
<select class="p-1.5 rounded-md mb-2" name="provider" id="provider" value="{{ mediaPreferences['provider'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ "" == mediaPreferences['provider'].getPreferenceValue() ? "selected" }}
>n/a</option>
{% for provider in providers %}
<option class="text-gray-800"
value="{{ provider }}"
{{ provider == mediaPreferences['provider'].getPreferenceValue() ? "selected" }}
>{{ provider }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="language">Language</label>
<select class="p-1.5 rounded-md mb-2" name="language" id="language" value="{{ mediaPreferences['language'].getPreferenceValue() }}">
<option class="text-gray-800"
value=""
{{ mediaPreferences['language'].getPreferenceValue() is null ? "selected" }}
>n/a</option>
{% for language in languages %}
<option class="text-gray-800"
value="{{ language }}"
{{ language == mediaPreferences['language'].getPreferenceValue() ? "selected" }}
>{{ language }}</option>
{% endfor %}
</select>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card> </twig:Card>
<twig:Card title="Download Preferences" class="w-full"> <twig:Card title="Download Preferences" class="w-full">
<p class="text-gray-50 mb-2">Change how your downloads are stored.</p> <p class="text-gray-50 mb-4">Change how your downloads are stored.</p>
<form id="download_preferences" class="flex flex-col" name="download_preferences" method="post" action="{{ path('app_save_download_preferences') }}"> <form id="download_preferences" class="flex flex-col" name="download_preferences" method="post" action="{{ path('app_save_download_preferences') }}">
<div class="flex flex-row gap-2 mb-2"> <div class="flex flex-row gap-2 mb-2">
<input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" /> <input type="hidden" name="movie_folder" id="movie_folder_hidden" value="0" />