Compare commits

...

5 Commits

21 changed files with 163 additions and 48 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,9 +11,9 @@ readonly class MediaFileDto
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(),
filename: $fileInfo->getFilename(),
extension: $fileInfo->getExtension(),

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

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

View File

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

View File

@@ -13,6 +13,7 @@ use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -26,6 +27,7 @@ final class WebController extends AbstractController
private readonly Broadcaster $broadcaster,
) {}
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
{
@@ -35,18 +37,18 @@ final class WebController extends AbstractController
$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));
$results = $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,
]);
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,
]);
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
@@ -61,18 +63,18 @@ final class WebController extends AbstractController
);
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));
$results = $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,
]);
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,
]);
} 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,11 +6,13 @@ 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;
#[AsLiveComponent]
final class TvEpisodeList
final class TvEpisodeList
{
use DefaultActionTrait;
use PaginateTrait;
@@ -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

@@ -73,18 +73,15 @@
{{ stimulus_action('result_filter', 'setSeason', 'change') }}
{{ stimulus_action('result_filter', 'uncheckSelectAllBtn', 'change') }}
>
<option selected value="1">1</option>
{% for season in range(2, results.media.episodes|length) %}
<option value="{{ season }}">{{ season }}</option>
{% for season in range(1, results.media.episodes|length) %}
<option value="{{ season }}"
{% if results.season == season %}
selected="selected"
{% endif %}
>{{ season }}</option>
{% endfor %}
</select>
</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 %}
<span {{ stimulus_controller('loading_icon', {total: (results.media.mediaType == "tvshows") ? results.media.episodes[1]|length : 1, count: 0}) }}
class="loading-icon">

View File

@@ -3,7 +3,19 @@
<a href="{{ path('app_search_result', {imdbId: monitor.imdbId, mediaType: monitor.monitorType|as_download_type}) }}"
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>
</td>
<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">
<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>