fix: links to episodes from downloads

This commit is contained in:
2025-07-22 22:49:07 -05:00
parent 24355a4b30
commit dc9242d96e
15 changed files with 115 additions and 12 deletions

1
assets/bootstrap.js vendored
View File

@@ -2,6 +2,7 @@ import { startStimulusApp } from '@symfony/stimulus-bundle';
import Popover from '@stimulus-components/popover'
import Dialog from '@stimulus-components/dialog'
import Dropdown from '@stimulus-components/dropdown'
import 'animate.css'
const app = startStimulusApp();
// register any custom, 3rd party controllers here

View File

@@ -14,6 +14,11 @@ export default class extends Controller {
this.component.on('render:finished', (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) {
@@ -25,6 +30,7 @@ export default class extends Controller {
paginate(event) {
this.element.querySelectorAll(".episode-container").forEach(element => element.remove());
this.component.set('episodeNumber', null);
this.component.action('paginate', {page: event.params.page});
this.component.render();
}

View File

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

View File

@@ -11,5 +11,6 @@ class GetMediaInfoCommand implements CommandInterface
public string $imdbId,
public string $mediaType,
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);
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)]
public ?int $season,
#[SourceRoute('episode', nullify: true)]
public ?int $episode,
) {}
public function toCommand(): CommandInterface
@@ -26,6 +29,10 @@ class GetMediaInfoInput implements InputInterface
if ("tvshows" === $this->mediaType && null === $this->season) {
$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 TmdbResult $media,
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(
GetMediaInfoInput $input,
?int $season = null,

View File

@@ -6,6 +6,8 @@ use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Handler\GetMediaInfoHandler;
use App\Search\TvEpisodePaginator;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
@@ -27,6 +29,12 @@ final class TvEpisodeList
#[LiveProp(writable: true)]
public int $season = 1;
#[LiveProp(writable: true)]
public int $reloadCount = 0;
#[LiveProp(writable: true)]
public ?int $episodeNumber = null;
public function __construct(
private GetMediaInfoHandler $getMediaInfoHandler,
) {}
@@ -34,6 +42,14 @@ final class TvEpisodeList
public function getEpisodes()
{
$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);
}

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\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Twig\Dto\EpisodeIdDto;
use ChrisUllyott\FileSize;
use Twig\Attribute\AsTwigFilter;
use Twig\Attribute\AsTwigFunction;
@@ -63,4 +64,42 @@ class UtilExtension
return "S". str_pad($season, 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

@@ -1,8 +1,15 @@
<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"
>
{% if download.mediaType == "movies" %}
{% 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 }}
</a>

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">
<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">

View File

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

View File

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