From e070b95a367d651e3efe612125be88152e131bd8 Mon Sep 17 00:00:00 2001 From: Brock H Caldwell Date: Thu, 19 Jun 2025 13:30:22 -0500 Subject: [PATCH] wip: working episode pagination, season switcher, monitor only new content --- .../controllers/result_filter_controller.js | 6 +- .../controllers/tv_episode_list_controller.js | 52 +++++++++++++ assets/controllers/tv_results_controller.js | 6 +- src/Controller/IndexController.php | 10 +++ src/Controller/SearchController.php | 5 +- .../Action/Handler/MonitorTvShowHandler.php | 20 +++++ .../Action/Command/GetMediaInfoCommand.php | 1 + .../Action/Handler/GetMediaInfoHandler.php | 2 +- src/Search/Action/Input/GetMediaInfoInput.php | 9 ++- .../Action/Result/GetMediaInfoResult.php | 1 + src/Search/TvEpisodePaginator.php | 65 ++++++++++++++++ src/Tmdb/Tmdb.php | 21 ++++++ src/Twig/Components/TvEpisodeList.php | 44 +++++++++++ src/Twig/Extensions/UtilExtension.php | 7 ++ src/Util/Paginator.php | 10 +++ templates/components/Filter.html.twig | 5 +- templates/components/TvEpisodeList.html.twig | 27 +++++++ templates/partial/paginator.html.twig | 34 ++++----- .../tv-episode-list-paginator.html.twig | 75 +++++++++++++++++++ templates/search/result.html.twig | 20 +---- 20 files changed, 378 insertions(+), 42 deletions(-) create mode 100644 assets/controllers/tv_episode_list_controller.js create mode 100644 src/Search/TvEpisodePaginator.php create mode 100644 src/Twig/Components/TvEpisodeList.php create mode 100644 templates/components/TvEpisodeList.html.twig create mode 100644 templates/partial/tv-episode-list-paginator.html.twig diff --git a/assets/controllers/result_filter_controller.js b/assets/controllers/result_filter_controller.js index 729d0bc..c0159ba 100644 --- a/assets/controllers/result_filter_controller.js +++ b/assets/controllers/result_filter_controller.js @@ -20,7 +20,7 @@ export default class extends Controller { "provider": "", } - static outlets = ['movie-results', 'tv-results'] + static outlets = ['movie-results', 'tv-results', 'tv-episode-list'] static targets = ['resolution', 'codec', 'language', 'provider', 'season', 'selectAll', 'downloadSelected'] static values = { 'media-type': String, @@ -127,6 +127,10 @@ export default class extends Controller { } } + setSeason(event) { + this.tvEpisodeListOutlet.setSeason(event.target.value); + } + uncheckSelectAllBtn() { this.selectAllTarget.checked = false; } diff --git a/assets/controllers/tv_episode_list_controller.js b/assets/controllers/tv_episode_list_controller.js new file mode 100644 index 0000000..8938867 --- /dev/null +++ b/assets/controllers/tv_episode_list_controller.js @@ -0,0 +1,52 @@ +import { Controller } from '@hotwired/stimulus'; +import { getComponent } from '@symfony/ux-live-component'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers +*/ + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + async initialize() { + this.component = await getComponent(this.element); + this.component.on('render:finished', (component) => { + console.log(component); + }); + } + + setSeason(season) { + this.element.querySelectorAll(".episode-container").forEach(element => element.remove()); + this.component.set('pageNumber', 1); + this.component.set('season', parseInt(season)); + this.component.render(); + } + + paginate(event) { + console.log(event.params.page) + this.element.querySelectorAll(".episode-container").forEach(element => element.remove()); + this.component.action('paginate', {page: event.params.page}); + this.component.render(); + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } +} diff --git a/assets/controllers/tv_results_controller.js b/assets/controllers/tv_results_controller.js index 408ae50..87b6caa 100644 --- a/assets/controllers/tv_results_controller.js +++ b/assets/controllers/tv_results_controller.js @@ -29,7 +29,7 @@ export default class extends Controller { } async setOptions() { - if (true === this.activeValue && this.optionsLoaded === false) { + if (this.optionsLoaded === false) { this.optionsLoaded = true; await fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`) .then(res => res.text()) @@ -56,7 +56,7 @@ export default class extends Controller { async setActive() { this.activeValue = true; - this.element.classList.remove('hidden'); + // this.element.classList.remove('hidden'); if (false === this.optionsLoaded) { await this.setOptions(); } @@ -67,7 +67,7 @@ export default class extends Controller { // if (true === this.hasEpisodeSelectorTarget()) { this.episodeSelectorTarget.checked = false; // } - this.element.classList.add('hidden'); + // this.element.classList.add('hidden'); } isActive() { diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index bcbcddb..7bed2f7 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -3,6 +3,8 @@ namespace App\Controller; use App\Download\Framework\Repository\DownloadRepository; +use App\Monitor\Action\Command\MonitorTvShowCommand; +use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Tmdb\Tmdb; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -13,6 +15,7 @@ final class IndexController extends AbstractController { public function __construct( private readonly Tmdb $tmdb, + private readonly MonitorTvShowHandler $monitorTvShowHandler, ) {} #[Route('/', name: 'app_index')] @@ -25,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); + } } diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index de95c04..bcfeaf6 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -33,13 +33,14 @@ final class SearchController extends AbstractController ]); } - #[Route('/result/{mediaType}/{imdbId}', name: 'app_search_result')] + #[Route('/result/{mediaType}/{imdbId}/{season}', name: 'app_search_result')] public function result( GetMediaInfoInput $input, + ?int $season = null, ): Response { $result = $this->getMediaInfoHandler->handle($input->toCommand()); - $this->warmDownloadOptionCache($result->media); +// $this->warmDownloadOptionCache($result->media); return $this->render('search/result.html.twig', [ 'results' => $result, diff --git a/src/Monitor/Action/Handler/MonitorTvShowHandler.php b/src/Monitor/Action/Handler/MonitorTvShowHandler.php index 3d65bc2..24f5da8 100644 --- a/src/Monitor/Action/Handler/MonitorTvShowHandler.php +++ b/src/Monitor/Action/Handler/MonitorTvShowHandler.php @@ -10,6 +10,7 @@ use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Framework\Repository\MonitorRepository; use App\Monitor\Service\MediaFiles; use App\Tmdb\Tmdb; +use Carbon\Carbon; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Nihilarr\PTN; @@ -55,9 +56,18 @@ readonly class MonitorTvShowHandler implements HandlerInterface $this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle()); + $episodeMonitors = []; if ($downloadedEpisodes->count() !== $episodesInShow->count()) { // Dispatch Episode commands for each missing Episode foreach ($episodesInShow as $episode) { + // Only monitor future episodes + $episodeInFuture = $this->episodeInFuture($episode); + $this->logger->info('> [MonitorTvShowHandler] Episode is in future for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeInFuture ? 'YES' : 'NO')); + if (false === $episodeInFuture) { + $this->logger->info('> [MonitorTvShowHandler] Episode not in future for title: ' . 'for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ', skipping'); + continue; + } + // Check if the episode is already downloaded $episodeExists = $this->episodeExists($episode, $downloadedEpisodes); $this->logger->info('> [MonitorTvShowHandler] Episode exists for season ' . $episode['season_number'] . ' episode ' . $episode['episode_number'] . ' for title: ' . $monitor->getTitle() . ' ? ' . (true === $episodeExists ? 'YES' : 'NO')); @@ -91,6 +101,8 @@ readonly class MonitorTvShowHandler implements HandlerInterface $this->monitorRepository->getEntityManager()->persist($episodeMonitor); $this->monitorRepository->getEntityManager()->flush(); + $episodeMonitors[] = $episodeMonitor; + // Immediately run the monitor $command = new MonitorTvEpisodeCommand($episodeMonitor->getId()); $this->monitorTvEpisodeHandler->handle($command); @@ -108,10 +120,18 @@ readonly class MonitorTvShowHandler implements HandlerInterface status: 'OK', result: [ 'monitor' => $monitor, + 'new_monitors' => $episodeMonitors, ] ); } + private function episodeInFuture(array $episodeInShow): bool + { + static $today = Carbon::today(); + $episodeAirDate = Carbon::parse($episodeInShow['air_date']); + return $episodeAirDate > $today; + } + private function episodeExists(array $episodeInShow, Map $downloadedEpisodes): bool { return $downloadedEpisodes->filter( diff --git a/src/Search/Action/Command/GetMediaInfoCommand.php b/src/Search/Action/Command/GetMediaInfoCommand.php index 22b489d..403e021 100644 --- a/src/Search/Action/Command/GetMediaInfoCommand.php +++ b/src/Search/Action/Command/GetMediaInfoCommand.php @@ -10,5 +10,6 @@ class GetMediaInfoCommand implements CommandInterface public function __construct( public string $imdbId, public string $mediaType, + public ?int $season = null, ) {} } \ No newline at end of file diff --git a/src/Search/Action/Handler/GetMediaInfoHandler.php b/src/Search/Action/Handler/GetMediaInfoHandler.php index 77ac9ed..24ae107 100644 --- a/src/Search/Action/Handler/GetMediaInfoHandler.php +++ b/src/Search/Action/Handler/GetMediaInfoHandler.php @@ -20,6 +20,6 @@ class GetMediaInfoHandler implements HandlerInterface { $media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType); - return new GetMediaInfoResult($media); + return new GetMediaInfoResult($media, $command->season); } } diff --git a/src/Search/Action/Input/GetMediaInfoInput.php b/src/Search/Action/Input/GetMediaInfoInput.php index 58eca1f..774f6d0 100644 --- a/src/Search/Action/Input/GetMediaInfoInput.php +++ b/src/Search/Action/Input/GetMediaInfoInput.php @@ -3,6 +3,7 @@ namespace App\Search\Action\Input; use App\Download\Action\Command\DownloadMediaCommand; +use App\Enum\MediaType; use App\Search\Action\Command\GetMediaInfoCommand; use OneToMany\RichBundle\Attribute\SourceRoute; use OneToMany\RichBundle\Contract\CommandInterface; @@ -17,10 +18,16 @@ class GetMediaInfoInput implements InputInterface #[SourceRoute('mediaType')] public string $mediaType, + + #[SourceRoute('season', nullify: true)] + public ?int $season, ) {} public function toCommand(): CommandInterface { - return new GetMediaInfoCommand($this->imdbId, $this->mediaType); + if ("tvshows" === $this->mediaType && null === $this->season) { + $this->season = 1; + } + return new GetMediaInfoCommand($this->imdbId, $this->mediaType, $this->season); } } \ No newline at end of file diff --git a/src/Search/Action/Result/GetMediaInfoResult.php b/src/Search/Action/Result/GetMediaInfoResult.php index 8732049..7d45c92 100644 --- a/src/Search/Action/Result/GetMediaInfoResult.php +++ b/src/Search/Action/Result/GetMediaInfoResult.php @@ -10,5 +10,6 @@ class GetMediaInfoResult implements ResultInterface { public function __construct( public TmdbResult $media, + public ?int $season, ) {} } diff --git a/src/Search/TvEpisodePaginator.php b/src/Search/TvEpisodePaginator.php new file mode 100644 index 0000000..54ddb0b --- /dev/null +++ b/src/Search/TvEpisodePaginator.php @@ -0,0 +1,65 @@ +total = count($results->media->episodes[$results->season]); + $this->lastPage = (int) ceil($this->total / $limit); + $this->items = array_slice($results->media->episodes[$results->season], ($page - 1) * $limit, $limit); + $this->currentPage = $page; + $this->limit = $limit; + + return $this; + } + + public function getTotal(): int + { + return $this->total; + } + + public function getLastPage(): int + { + return $this->lastPage; + } + + public function getItems() + { + return $this->items; + } + + public function getShowing() + { + $showingStart = (($this->currentPage - 1) * $this->limit) + 1; + $showingEnd = (($this->currentPage - 1) * $this->limit) + $this->limit; + + if ($showingEnd > $this->total) { + $showingEnd = $this->total; + } + + return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total); + } +} diff --git a/src/Tmdb/Tmdb.php b/src/Tmdb/Tmdb.php index ea1e847..82ecfdf 100644 --- a/src/Tmdb/Tmdb.php +++ b/src/Tmdb/Tmdb.php @@ -2,6 +2,7 @@ namespace App\Tmdb; +use Aimeos\Map; use App\Enum\MediaType; use App\ValueObject\ResultFactory; use Symfony\Component\DependencyInjection\Attribute\Autowire; @@ -97,6 +98,16 @@ class Tmdb return $this->parseResult($movies[$movie], "movie"); }); + $movies = Map::from($movies->toArray())->filter(function ($movie) { + return $movie !== null + && $movie->imdbId !== null + && $movie->tmdbId !== null + && $movie->title !== null + && $movie->poster !== null + && $movie->description !== null + && $movie->mediaType !== null; + }); + $movies = array_values($movies->toArray()); if (null !== $limit) { @@ -114,6 +125,16 @@ class Tmdb return $this->parseResult($movies[$movie], "movie"); }); + $movies = Map::from($movies->toArray())->filter(function ($movie) { + return $movie !== null + && $movie->imdbId !== null + && $movie->tmdbId !== null + && $movie->title !== null + && $movie->poster !== null + && $movie->description !== null + && $movie->mediaType !== null; + }); + $movies = array_values($movies->toArray()); if (null !== $limit) { diff --git a/src/Twig/Components/TvEpisodeList.php b/src/Twig/Components/TvEpisodeList.php new file mode 100644 index 0000000..43cc60a --- /dev/null +++ b/src/Twig/Components/TvEpisodeList.php @@ -0,0 +1,44 @@ +getMediaInfoHandler->handle(new GetMediaInfoCommand($this->imdbId, "tvshows", $this->season)); + return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage); + } + + public function setPage(int $page) + { + $this->pageNumber = $page; + } +} diff --git a/src/Twig/Extensions/UtilExtension.php b/src/Twig/Extensions/UtilExtension.php index f707b3e..078677c 100644 --- a/src/Twig/Extensions/UtilExtension.php +++ b/src/Twig/Extensions/UtilExtension.php @@ -6,6 +6,7 @@ use App\Monitor\Framework\Entity\Monitor; use App\Monitor\Service\MediaFiles; use ChrisUllyott\FileSize; use Twig\Attribute\AsTwigFilter; +use Twig\Attribute\AsTwigFunction; class UtilExtension { @@ -14,6 +15,12 @@ class UtilExtension private readonly MediaFiles $mediaFiles, ) {} + #[AsTwigFunction('uniqid')] + public function uniqid(): string + { + return uniqid(); + } + #[AsTwigFilter('filesize')] public function type(string|int $size) { diff --git a/src/Util/Paginator.php b/src/Util/Paginator.php index 3abb4e1..3dd4a3d 100644 --- a/src/Util/Paginator.php +++ b/src/Util/Paginator.php @@ -22,6 +22,8 @@ class Paginator public $currentPage = 1; + public $limit = 5; + /** * @param QueryBuilder|Query $query * @param int $page @@ -41,6 +43,7 @@ class Paginator $this->lastPage = (int) ceil($paginator->count() / $paginator->getQuery()->getMaxResults()); $this->items = $paginator; $this->currentPage = $page; + $this->limit = $limit; return $this; } @@ -59,4 +62,11 @@ class Paginator { return $this->items; } + + public function getShowing() + { + $showingStart = ($this->currentPage - 1) * $this->limit; + $showingEnd = $showingStart + $this->limit; + return sprintf("Showing %d - %d of %d results.", $showingStart, $showingEnd, $this->total); + } } \ No newline at end of file diff --git a/templates/components/Filter.html.twig b/templates/components/Filter.html.twig index 560fb8a..dd45ccb 100644 --- a/templates/components/Filter.html.twig +++ b/templates/components/Filter.html.twig @@ -4,6 +4,7 @@ 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" >