feat: tv episode results

This commit is contained in:
2025-04-21 22:39:22 -05:00
parent 7f29d737c1
commit d60fae24d1
9 changed files with 253 additions and 7 deletions

View File

@@ -0,0 +1,26 @@
import { Controller } from '@hotwired/stimulus';
/*
* The following line makes this controller "lazy": it won't be downloaded until needed
* See https://github.com/symfony/stimulus-bridge#lazy-controllers
*/
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static values = {
tmdbId: String,
imdbId: String,
season: String,
episode: String,
active: Boolean,
};
connect() {
if (true === this.activeValue) {
fetch(`/torrentio/tvshows/${this.tmdbIdValue}/${this.imdbIdValue}/${this.seasonValue}/${this.episodeValue}`)
.then(res => res.text())
.then(response => {
this.element.innerHTML = response;
});
}
}
}

View File

@@ -3,7 +3,9 @@
namespace App\Controller;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\Torrentio\Action\Input\GetMovieOptionsInput;
use App\Torrentio\Action\Input\GetTvShowOptionsInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -12,9 +14,10 @@ final class TorrentioController extends AbstractController
{
public function __construct(
private readonly GetMovieOptionsHandler $getMovieOptionsHandler,
private readonly GetTvShowOptionsHandler $getTvShowOptionsHandler,
) {}
#[Route('/torrentio/movies/{imdbId}', name: 'app_torrentio')]
#[Route('/torrentio/movies/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input): Response
{
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
@@ -23,4 +26,14 @@ final class TorrentioController extends AbstractController
'results' => $results,
]);
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
public function tvShowOptions(GetTvShowOptionsInput $input): Response
{
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
}
}

View File

@@ -27,6 +27,7 @@ use Tmdb\Model\Search\SearchQuery\MovieSearchQuery;
use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Repository\TvSeasonRepository;
use Tmdb\Token\Api\ApiToken;
@@ -139,7 +140,14 @@ class Tmdb
$client = new TvRepository($this->client);
$details = $client->getApi()->getTvshow($id, ['append_to_response' => 'external_ids,seasons']);
$details = $this->getEpisodesFromSeries($details);
return $this->parseResult($details, "tv");
return $this->parseResult($details, "tvshow");
}
public function episodeDetails(string $id, string $season, string $episode)
{
$client = new TvEpisodeRepository($this->client);
$result = $client->getApi()->getEpisode($id, $season, $episode, ['append_to_response' => 'external_ids']);
return $this->parseResult($result, "episode");
}
public function getEpisodesFromSeries(array $series)
@@ -192,6 +200,19 @@ class Tmdb
);
}
function parseEpisode(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: $posterBasePath . $data['still_path'],
description: $data['overview'],
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
);
}
function parseMovie(array $data, string $posterBasePath): TmdbResult {
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
@@ -204,8 +225,13 @@ class Tmdb
);
}
$posterBasePath = self::POSTER_IMG_PATH;
$result = ("movie" === $mediaType) ? parseMovie($data, $posterBasePath) : parseTvShow($data, $posterBasePath);
if ($mediaType === 'movie') {
$result = parseMovie($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'tvshow') {
$result = parseTvShow($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'episode') {
$result = parseEpisode($data, self::POSTER_IMG_PATH);
}
return $result;
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Torrentio\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class GetTvShowOptionsCommand implements CommandInterface
{
public function __construct(
public string $tmdbId,
public string $imdbId,
public ?string $season,
public ?string $episode,
) {}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Torrentio\Action\Handler;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
) {}
public function handle(CommandInterface $command): ResultInterface
{
return new GetTvShowOptionsResult(
media: $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode),
season: $command->season,
episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults(
$command->imdbId,
$command->season,
$command->episode,
)
);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Torrentio\Action\Input;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class GetTvShowOptionsInput implements InputInterface
{
public function __construct(
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('imdbId')]
public string $imdbId,
#[SourceRoute('season', nullify: true)]
public ?string $season,
#[SourceRoute('episode', nullify: true)]
public string $episode,
) {}
public function toCommand(): CommandInterface
{
return new GetTvShowOptionsCommand(
$this->tmdbId,
$this->imdbId,
$this->season,
$this->episode
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Torrentio\Action\Result;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public string $season,
public string $episode,
public array $results
) {}
}

View File

@@ -19,8 +19,24 @@
</div>
</div>
{% if "movies" == results.media.mediaType %}
<div id="results" {{ stimulus_controller('movie_results', {imdbId: results.media.imdbId}) }}>
</div>
{% elseif "tvshows" == results.media.mediaType %}
{% for season, episodes in results.media.episodes %}
{% set active = (season == '1') ? true : false %}
{% for episode in episodes %}
<div id="results" {{ stimulus_controller('tv_results', {
tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
season: season,
episode: episode['episode_number'],
active: active,
}) }}>
</div>
{% endfor %}
{% endfor %}
{% endif %}
</twig:Card>
</div>

View File

@@ -1,3 +1,73 @@
<div class="p-2 flex flex-row gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<h3 class="text-md">Episodes</h3>
</div>
<div class="p-2 flex flex-col gap-6 bg-orange-500 bg-clip-padding backdrop-filter backdrop-blur-md bg-opacity-60 rounded-md">
<h4 class="text-md font-bold">Episode {{ results.episode }}</h4>
<div class="flex flex-row gap-4">
<img class="w-24 rounded-lg" src="{{ results.media.poster }}" />
<div class="flex flex-col gap-4">
<h5 class="text-md font-bold">{{ results.media.title }}</h5>
<p>{{ results.media.description }}</p>
</div>
</div>
<table class="divide-y divide-gray-200 dark:divide-gray-50 dark:bg-transparent rounded-lg">
<thead>
<tr class="dark:bg-stone-600 overflow-hidden rounded-md">
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-stone-500 uppercase dark:text-gray-50">
Size
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-gray-50">
Resolution
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-gray-50">
Codec
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-gray-50">
Seeders
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-gray-50">
Provider
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-gray-50">
Language
</th>
<th scope="col"
class="px-6 py-3 text-start text-xs font-medium text-gray-500 uppercase dark:text-gray-50">
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-50">
{% for result in results.results %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.size }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.resolution }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.codec }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.seeders }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.provider }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-800 dark:text-gray-50">
{{ result.languageFlags }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-end text-gray-800 dark:text-gray-50">
<span class="p-1.5 bg-green-600 rounded-md">
<span class="text-gray-50">Download</span>
</span>
<input class="ml-1" type="checkbox" name="select" />
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>