Compare commits

...

6 Commits

Author SHA1 Message Date
a2f16398be fix: composer.lock 2025-09-08 20:22:31 -05:00
0f03199eb4 fix: cascade removes monitors 2025-09-08 16:05:31 -05:00
d63d477ed1 chore: cleanup 2025-09-08 15:59:20 -05:00
458229c7ed feat: displays media genres 2025-09-08 14:36:41 -05:00
6748188256 fix: removes dd() 2025-09-08 14:21:50 -05:00
b42924048f chore: makes better use of symfony denormalizer 2025-09-08 14:20:33 -05:00
18 changed files with 518 additions and 196 deletions

View File

@@ -25,6 +25,8 @@
"p3k/emoji-detector": "^1.2", "p3k/emoji-detector": "^1.2",
"php-http/cache-plugin": "^2.0", "php-http/cache-plugin": "^2.0",
"php-tmdb/api": "^4.1", "php-tmdb/api": "^4.1",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.1",
"predis/predis": "^2.4", "predis/predis": "^2.4",
"runtime/frankenphp-symfony": "^0.2.0", "runtime/frankenphp-symfony": "^0.2.0",
"spatie/icalendar-generator": "^3.0", "spatie/icalendar-generator": "^3.0",
@@ -47,9 +49,12 @@
"symfony/notifier": "7.3.*", "symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*", "symfony/ntfy-notifier": "7.3.*",
"symfony/object-mapper": "7.3.*", "symfony/object-mapper": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*", "symfony/runtime": "7.3.*",
"symfony/scheduler": "7.3.*", "symfony/scheduler": "7.3.*",
"symfony/security-bundle": "7.3.*", "symfony/security-bundle": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.3.*", "symfony/twig-bundle": "7.3.*",
"symfony/ux-autocomplete": "^2.27", "symfony/ux-autocomplete": "^2.27",

View File

@@ -67,8 +67,6 @@ services:
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader" App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"
# Session # Session
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments: arguments:

View File

@@ -68,7 +68,7 @@ class Monitor
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
private ?self $parent = null; private ?self $parent = null;
#[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent')] #[ORM\OneToMany(targetEntity: self::class, mappedBy: 'parent', cascade: ['remove'])]
private Collection $children; private Collection $children;
public function __construct() public function __construct()

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class CastMemberDto
{
public function __construct(
#[SerializedPath('[name]')]
public string $name,
) {}
public function __toString(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class CrewMemberDto
{
public function __construct(
#[SerializedPath('[name]')]
public string $name,
) {}
public function __toString(): string
{
return $this->name;
}
}

18
src/Tmdb/Dto/GenreDto.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace App\Tmdb\Dto;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class GenreDto
{
public function __construct(
public int $id,
public string $name,
) {}
public function __toString(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Tmdb\Dto;
use App\Base\Enum\MediaType;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class TmdbEpisodeDto
{
public function __construct(
#[SerializedPath('[id]')]
public ?int $tmdbId = null,
#[SerializedPath('[show_id]')]
public ?int $tmdbShowId = null,
public ?string $mediaType = MediaType::TvShow->value,
public ?string $imdbId = null,
public ?string $name = null,
#[SerializedPath('[air_date]')]
public ?string $airDate = null,
#[SerializedPath('[overview]')]
public ?string $description = null,
public ?string $poster = null,
public ?int $runtime = 0,
#[SerializedPath('[season_number]')]
public ?int $seasonNumber = 0,
#[SerializedPath('[episode_number]')]
public ?int $episodeNumber = 0,
) {}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use App\Base\Enum\MediaType;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbMovieResultDenormalizer extends TmdbResultDenormalizer implements DenormalizerInterface
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {
parent::__construct($normalizer);
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
/** @var TmdbResult $result */
$result = parent::denormalize($data, TmdbResult::class, $format, $context);
if (array_key_exists('release_date', $data) && !in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']));
} else {
$airDate = null;
}
$result->title = $data['original_title'];
$result->premiereDate = $airDate;
$result->poster = (null !== $data['poster_path']) ? self::POSTER_IMG_PATH . $data['poster_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = MediaType::Movie->value;
return $result;
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return array_key_exists('media_type', $context) &&
$context['media_type'] === MediaType::Movie->value;
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbResultDenormalizer implements DenormalizerInterface
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
$result = $this->normalizer->denormalize($data, TmdbResult::class, $format, $context);
$result->stars = $this->getStars($data);
$result->directors = $this->getDirectors($data);
$result->producers = $this->getProducers($data);
$result->creators = $this->getCreators($data);
return $result;
}
protected function getStars(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['cast'])
->slice(0, 3)
->map(fn($item) => $this->normalizer->denormalize($item, CastMemberDto::class))
->toArray();
}
protected function getDirectors(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['crew'])
->filter(fn($item) => $item['job'] === 'Director')
->slice(0, 3)
->map(fn($item) => $this->normalizer->denormalize($item, CrewMemberDto::class))
->toArray();
}
public function getCreators(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['crew'])
->filter(fn($item) => $item['job'] === 'Creator')
->map(fn($item) => $this->normalizer->denormalize($item, CrewMemberDto::class))
->toArray();
}
public function getProducers(array $data): ?array
{
if (!array_key_exists('credits', $data)) {
return null;
}
return Map::from($data['credits']['crew'])
->filter(fn($item) => $item['job'] === 'Producer')
->map(fn($item) => $this->normalizer->denormalize($item, CrewMemberDto::class))
->toArray();
}
public function getGenres(array $data, MediaType $mediaType): ?array
{
if (array_key_exists('genres', $data)) {
return null;
}
return Map::from($data['genres'])
->map(fn($item) => $this->normalizer->denormalize($item, GenreDto::class))
->toArray();
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return !array_key_exists('media_type', $context);
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use App\Base\Enum\MediaType;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbTvEpisodeResultDenormalizer implements DenormalizerInterface
{
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
/** @var TmdbEpisodeDto $result */
$result = $this->normalizer->denormalize($data, TmdbResult::class, $format, $context);
return $result;
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return array_key_exists('media_type', $context) &&
$context['media_type'] === MediaType::TvEpisode->value;
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Tmdb\Framework\Serializer;
use App\Base\Enum\MediaType;
use App\Tmdb\TmdbResult;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class TmdbTvShowResultDenormalizer extends TmdbResultDenormalizer implements DenormalizerInterface
{
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {
parent::__construct($normalizer);
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
/** @var TmdbResult $result */
$result = parent::denormalize($data, TmdbResult::class, $format, $context);
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']));
} else {
$airDate = null;
}
if (array_key_exists('seasons', $data)) {
$result->numberSeasons = count($data['seasons']);
}
$result->title = $data['original_name'];
$result->premiereDate = $airDate;
$result->poster = (null !== $data['poster_path']) ? self::POSTER_IMG_PATH . $data['poster_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = MediaType::TvShow->value;
return $result;
}
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return array_key_exists('media_type', $context) &&
$context['media_type'] === "tvshows";
}
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => false,
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Tmdb;
use Aimeos\Map; use Aimeos\Map;
use App\Base\Enum\MediaType; use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher; use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto;
use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -37,6 +38,16 @@ class TmdbClient
protected TvEpisodeRepository $tvEpisodeRepository; protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository; protected SearchRepository $searchRepository;
protected array $mediaTypeMap = [
MediaType::Movie->value => MediaType::Movie->value,
MediaType::TvShow->value => MediaType::TvShow->value,
MediaType::TvEpisode->value => MediaType::TvEpisode->value,
'movie' => 'movies',
'tv' => 'tvshows',
];
protected $repos = [];
public function __construct( public function __construct(
private readonly SerializerInterface $serializer, private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache, private readonly CacheItemPoolInterface $cache,
@@ -96,6 +107,11 @@ class TmdbClient
$this->tvSeasonRepository = new TvSeasonRepository($this->client); $this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client); $this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client); $this->searchRepository = new SearchRepository($this->client);
$this->repos = [
MediaType::Movie->value => $this->movieRepository,
MediaType::TvShow->value => $this->tvRepository,
MediaType::TvEpisode->value => $this->tvEpisodeRepository,
];
} }
public function search(string $term): TmdbResult|Map public function search(string $term): TmdbResult|Map
@@ -109,17 +125,15 @@ class TmdbClient
$handler = $handlers[$data['media_type']]; $handler = $handlers[$data['media_type']];
return $this->$handler($term); return $this->$handler($term);
} }
return $this->parseListOfResults( $results = $this->searchRepository->getApi()->searchMulti($term);
$this->searchRepository->getApi()->searchMulti($term), return $this->parseListOfResults($results);
"movies"
);
} }
public function movieDetails(string $imdbId): ?TmdbResult public function movieDetails(string $imdbId): ?TmdbResult
{ {
$tmdbId = $this->findByImdbId($imdbId)['id']; $tmdbId = $this->findByImdbId($imdbId)['id'];
return $this->parseResult( return $this->parseResult(
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids']), $this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids,credits']),
MediaType::Movie->value, MediaType::Movie->value,
$imdbId $imdbId
); );
@@ -128,7 +142,7 @@ class TmdbClient
public function tvshowDetails(string $imdbId): ?TmdbResult public function tvshowDetails(string $imdbId): ?TmdbResult
{ {
$tmdbId = $this->findByImdbId($imdbId)['id']; $tmdbId = $this->findByImdbId($imdbId)['id'];
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids']); $media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids,credits']);
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) { $media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 && return $data['season_number'] !== 0 &&
strtolower($data['name']) !== 'specials'; strtolower($data['name']) !== 'specials';
@@ -147,7 +161,7 @@ class TmdbClient
public function tvSeasonDetails(string $tmdbId, int $season): array public function tvSeasonDetails(string $tmdbId, int $season): array
{ {
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season); $result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => 'external_ids,credits']);
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) { $result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path']; $data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path']; $data['poster'] = $data['still_path'];
@@ -156,9 +170,9 @@ class TmdbClient
return $result; return $result;
} }
public function tvEpisodeDetails(string $tmdbId, int $season, int $episode): ?TmdbResult public function tvEpisodeDetails(string $tmdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null
{ {
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => 'external_ids']); $result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => 'external_ids,credits']);
return $this->parseResult( return $this->parseResult(
$result, $result,
MediaType::TvEpisode->value, MediaType::TvEpisode->value,
@@ -168,56 +182,46 @@ class TmdbClient
public function relatedMedia(string $tmdbId, string $mediaType, int $resultCount = 6): Map public function relatedMedia(string $tmdbId, string $mediaType, int $resultCount = 6): Map
{ {
$repos = [ $results = $this->repos[$mediaType]->getApi()->getRecommendations($tmdbId, ['append_to_response' => 'external_ids']);
'movies' => $this->movieRepository,
'tvshows' => $this->tvRepository,
];
$results = $repos[$mediaType]->getApi()->getRecommendations($tmdbId);
return $this->parseListOfResults( return $this->parseListOfResults(
$results, $results,
$mediaType,
$resultCount $resultCount
); );
} }
public function popularMovies(int $resultCount = 6): Map public function popularMovies(int $resultCount = 6): Map
{ {
$results = $this->movieRepository->getApi()->getPopular();
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::Movie->value;
return $result;
});
return $this->parseListOfResults( return $this->parseListOfResults(
$this->movieRepository->getApi()->getPopular(), $results,
"movies",
$resultCount $resultCount
); );
} }
public function popularTvShows(int $resultCount = 6): Map public function popularTvShows(int $resultCount = 6): Map
{ {
$results = $this->tvRepository->getApi()->getPopular();
$results['results'] = Map::from($results['results'])->map(function ($result) {
$result['media_type'] = MediaType::TvShow->value;
return $result;
});
return $this->parseListOfResults( return $this->parseListOfResults(
$this->tvRepository->getApi()->getPopular(), $results,
"tvshows",
$resultCount $resultCount
); );
} }
private function getExternalIds(string $tmdbId, $mediaType): ?array private function getExternalIds(int $tmdbId, string $mediaType): ?array
{ {
try { if (!array_key_exists($mediaType, $this->repos) ||
switch (MediaType::tryFrom($mediaType)->value) { !in_array($mediaType, [MediaType::Movie->value, MediaType::TvShow->value])) {
case MediaType::Movie->value:
$externalIds = $this->movieRepository->getApi()->getExternalIds($tmdbId);
break;
case MediaType::TvShow->value:
$externalIds = $this->tvRepository->getApi()->getExternalIds($tmdbId);
break;
default:
$externalIds = null;
break;
}
} catch (\throwable $e) {
return []; return [];
} }
return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
return $externalIds;
} }
private function findByImdbId(string $imdbId): array private function findByImdbId(string $imdbId): array
@@ -236,7 +240,7 @@ class TmdbClient
throw new \Exception("No results found for $imdbId"); throw new \Exception("No results found for $imdbId");
} }
private function parseResult(array $data, string $mediaType, string $imdbId): TmdbResult private function parseResult(array $data, string $mediaType, string $imdbId): TmdbResult|TmdbEpisodeDto
{ {
if (!array_key_exists('external_ids', $data)) { if (!array_key_exists('external_ids', $data)) {
$data['external_ids'] = ['imdb_id' => $imdbId]; $data['external_ids'] = ['imdb_id' => $imdbId];
@@ -244,20 +248,21 @@ class TmdbClient
return $this->serializer->denormalize($data, TmdbResult::class, context: ['media_type' => $mediaType]); return $this->serializer->denormalize($data, TmdbResult::class, context: ['media_type' => $mediaType]);
} }
private function parseListOfResults(array $data, string $mediaType, ?int $resultCount = null): Map private function parseListOfResults(array $data, ?int $resultCount = null): Map
{ {
$results = Map::from($data['results'])->filter( $results = Map::from($data['results'])->filter(function ($result) {
fn ($result) => array_key_exists('id', $result) return array_key_exists('media_type', $result) &&
)->map(function ($result) { in_array($result['media_type'], array_keys($this->mediaTypeMap));
$result['external_ids'] = $this->getExternalIds($result['id'], MediaType::Movie->value); })->map(function ($result) {
$result['external_ids'] = $this->getExternalIds($result['id'], $this->mediaTypeMap[$result['media_type']]);
return $result; return $result;
})->filter(function ($result) { })->filter(function ($result) {
return array_key_exists('id', $result) && return array_key_exists('id', $result) &&
array_key_exists('imdb_id', $result['external_ids']) && array_key_exists('imdb_id', $result['external_ids']) &&
$result['external_ids']['imdb_id'] !== null && $result['external_ids']['imdb_id'] !== null &&
$result['external_ids']['imdb_id'] !== ""; $result['external_ids']['imdb_id'] !== "";
})->map(function ($result) use ($mediaType) { })->map(function ($result) {
return $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => $mediaType]); return $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => $this->mediaTypeMap[$result['media_type']]]);
}); });
if (null !== $resultCount) { if (null !== $resultCount) {

View File

@@ -2,10 +2,35 @@
namespace App\Tmdb; namespace App\Tmdb;
use App\Base\Enum\MediaType;
use App\Tmdb\Dto\CastMemberDto;
use App\Tmdb\Dto\CrewMemberDto;
use App\Tmdb\Dto\GenreDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use Symfony\Component\Serializer\Attribute\Context;
use Symfony\Component\Serializer\Attribute\SerializedPath; use Symfony\Component\Serializer\Attribute\SerializedPath;
class TmdbResult class TmdbResult
{ {
/**
* @param string|null $imdbId
* @param int|null $tmdbId
* @param string|null $title
* @param string|null $description
* @param string|null $poster
* @param \DateTimeInterface|null $premiereDate
* @param string|null $year
* @param string|null $mediaType
* @param array<TmdbEpisodeDto[]>|null $episodes
* @param string|null $episodeAirDate
* @param GenreDto[]|null $genres
* @param CastMemberDto[]|null $stars
* @param CrewMemberDto[]|null $directors
* @param CrewMemberDto[]|null $creators
* @param CrewMemberDto[]|null $producers
* @param int|null $runtime
* @param int|null $numberSeasons
*/
public function __construct( public function __construct(
#[SerializedPath('[external_ids][imdb_id]')] #[SerializedPath('[external_ids][imdb_id]')]
public ?string $imdbId = "", public ?string $imdbId = "",
@@ -18,8 +43,18 @@ class TmdbResult
public ?\DateTimeInterface $premiereDate = null, public ?\DateTimeInterface $premiereDate = null,
public ?string $year = null, public ?string $year = null,
public ?string $mediaType = "", public ?string $mediaType = "",
#[Context(denormalizationContext: [
'media_type' => MediaType::TvEpisode->value
])]
#[SerializedPath('[seasons]')] #[SerializedPath('[seasons]')]
public ?array $episodes = null, public ?array $episodes = null,
public ?string $episodeAirDate = null, public ?string $episodeAirDate = null,
public ?array $genres = null,
public ?array $stars = null,
public ?array $directors = null,
public ?array $creators = null,
public ?array $producers = null,
public ?int $runtime = null,
public ?int $numberSeasons = null,
) {} ) {}
} }

View File

@@ -1,111 +0,0 @@
<?php
namespace App\Tmdb;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Contracts\Cache\ItemInterface;
class TmdbResultDenormalizer implements DenormalizerInterface
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
#[Autowire(service: 'serializer.normalizer.object')]
private readonly NormalizerInterface $normalizer,
) {}
/**
* @inheritDoc
*/
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): TmdbResult|array|null
{
$parsers = [
MediaType::Movie->value => 'parseMovie',
MediaType::TvShow->value => 'parseTvShow',
'movie' => 'parseMovie',
'tv' => 'parseTvShow',
'tvepisode' => 'parseEpisode',
];
$denormalized = $this->normalizer->denormalize($data, TmdbResult::class, $format, $context);
$parser = array_key_exists('media_type', $data) ? $parsers[$data['media_type']] : $parsers[$context['media_type']];
return $this->$parser($data, $denormalized);
}
private function parseTvShow(array $data, TmdbResult $result): TmdbResult
{
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']));
} else {
$airDate = null;
}
$result->title = $data['original_name'];
$result->premiereDate = $airDate;
$result->poster = (null !== $data['poster_path']) ? self::POSTER_IMG_PATH . $data['poster_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = "tvshows";
return $result;
}
private function parseMovie(array $data, TmdbResult $result): TmdbResult
{
if (!in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']));
} else {
$airDate = null;
}
$result->title = $data['original_title'];
$result->premiereDate = $airDate;
$result->poster = (null !== $data['poster_path']) ? self::POSTER_IMG_PATH . $data['poster_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = "movies";
return $result;
}
private function parseEpisode(array $data, TmdbResult $result): TmdbResult
{
if (!in_array($data['air_date'], ['', null,])) {
$airDate = (new \DateTime($data['air_date']));
} else {
$airDate = null;
}
$result->premiereDate = $airDate;
$result->episodeAirDate = $airDate->format('m/d/Y');
$result->poster = (null !== $data['still_path']) ? self::POSTER_IMG_PATH . $data['still_path'] : null;
$result->year = (null !== $airDate) ? $airDate->format('Y') : null;
$result->mediaType = "tvshows";
return $result;
}
/**
* @inheritDoc
*/
public function supportsDenormalization(
mixed $data,
string $type,
?string $format = null,
array $context = []
): bool {
return $type === TmdbResult::class;
}
/**
* @inheritDoc
*/
public function getSupportedTypes(?string $format): array
{
return [
TmdbResult::class => true,
];
}
}

View File

@@ -3,14 +3,15 @@
namespace App\Torrentio\Action\Result; namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto; use App\Library\Dto\MediaFileDto;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\TmdbResult; use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsResult implements ResultInterface class GetTvShowOptionsResult implements ResultInterface
{ {
public function __construct( public function __construct(
public TmdbResult $parentShow, public TmdbResult|TmdbEpisodeDto $parentShow,
public TmdbResult $media, public TmdbResult|TmdbEpisodeDto $media,
public MediaFileDto|false $file, public MediaFileDto|false $file,
public string $season, public string $season,
public string $episode, public string $episode,

View File

@@ -3,7 +3,7 @@
> >
<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.getEpisodes().items %} {% for episode in this.getEpisodes().items %}
<episode-container id="{{ episode_anchor(episode['season_number'], episode['episode_number']) }}" class="results" <episode-container id="{{ episode_anchor(episode.seasonNumber, episode.episodeNumber) }}" class="results"
show-title="{{ this.title }}" show-title="{{ this.title }}"
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"
@@ -11,15 +11,15 @@
title: this.title, title: this.title,
tmdbId: this.tmdbId, tmdbId: this.tmdbId,
imdbId: this.imdbId, imdbId: this.imdbId,
season: episode['season_number'], season: episode.seasonNumber,
episode: episode['episode_number'], episode: episode.episodeNumber,
active: 'true', active: 'true',
}) }} }) }}
> >
<div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500/60 bg-clip-padding backdrop-filter backdrop-blur-md rounded-md"> <div class="p-4 md:p-6 flex flex-col gap-6 bg-orange-500/60 bg-clip-padding backdrop-filter backdrop-blur-md rounded-md">
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
{% if episode['poster'] != null %} {% if episode.poster != null %}
<img class="w-full md:w-64 rounded-lg" src="{{ episode['poster'] }}" /> <img class="w-full md:w-64 rounded-lg" src="{{ episode.poster }}" />
{% else %} {% else %}
<div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center"> <div class="w-full md:w-64 min-w-64 sticky h-[144px] rounded-lg bg-gray-700 flex items-center justify-center">
<twig:ux:icon width="32" name="hugeicons:loading-01" /> <twig:ux:icon width="32" name="hugeicons:loading-01" />
@@ -27,28 +27,28 @@
{% endif %} {% endif %}
<div class="flex flex-col gap-4 grow"> <div class="flex flex-col gap-4 grow">
<h4 class="text-md font-bold"> <h4 class="text-md font-bold">
{{ episode['episode_number'] }}. {{ episode['name'] }} {{ episode.episodeNumber }}. {{ episode.name }}
</h4> </h4>
<p>{{ episode['overview']|truncate }}</p> <p>{{ episode.description|truncate }}</p>
<div> <div class="text-xs font-bold">
<button class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode['season_number'] }} episode {{ episode['episode_number'] }}."> <button class="results-count-badge py-1 px-1.5 mr-1 grow-0 bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white" title="Click to expand the results table for season {{ episode.seasonNumber }} episode {{ episode.episodeNumber }}.">
<span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}>-</span> results <span class="results-count-number" {{ stimulus_target('tv-results', 'count') }}>-</span> results
</button> </button>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title='"{{ episode['name'] }}" aired on {{ episode['air_date']|date(null, 'UTC') }}.'> <span class="py-1 px-1.5 mr-1 grow-0 bg-gray-700 rounded-lg text-white" title='"{{ episode.name }}" aired on {{ episode.airDate|date(null, 'UTC') }}.'>
{{ episode['air_date']|date(null, 'UTC') }} {{ episode.airDate|date(null, 'UTC') }}
</small> </span>
<twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('api.library.search', { <twig:Turbo:Frame id="meb_{{ this.imdbId }}_{{ episode_id(episode.seasonNumber, episode.episodeNumber) }}" src="{{ path('api.library.search', {
title: this.title, title: this.title,
season: episode['season_number'], season: episode.seasonNumber,
episode: episode['episode_number'], episode: episode.episodeNumber,
block: 'media_exists_badge', block: 'media_exists_badge',
target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode['season_number'], episode['episode_number']) target: "meb_" ~ this.imdbId ~"_" ~ episode_id(episode.seasonNumber, episode.episodeNumber)
}) }}"> }) }}">
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet."> <span class="py-1 px-1.5 mr-1 grow-0 bg-rose-600 rounded-lg text-white" title="Episode has not been downloaded yet.">
missing missing
</small> </span>
</twig:Turbo:Frame> </twig:Turbo:Frame>
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@
{{ stimulus_target('tv-results', 'episodeSelector') }} {{ stimulus_target('tv-results', 'episodeSelector') }}
/> />
</div> </div>
<button class="dropdown-button flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90" title="Click to expand the results table for season {{ episode['season_number'] }} episode {{ episode['episode_number'] }}."> <button class="dropdown-button flex flex-col items-end transition-transform duration-300 ease-in-out rotate-90" title="Click to expand the results table for season {{ episode.seasonNumber }} episode {{ episode.episodeNumber }}.">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32">
<path <path
fill="none" fill="none"
@@ -72,12 +72,12 @@
</div> </div>
</div> </div>
<div class="results-container inline-block overflow-hidden rounded-lg hidden"> <div class="results-container inline-block overflow-hidden rounded-lg hidden">
<twig:Turbo:Frame id="results_{{ episode_id(episode['season_number'], episode['episode_number']) }}" src="{{ path('app_torrentio_tvshows', { <twig:Turbo:Frame id="results_{{ episode_id(episode.seasonNumber, episode.episodeNumber) }}" src="{{ path('app_torrentio_tvshows', {
tmdbId: this.tmdbId, tmdbId: this.tmdbId,
imdbId: this.imdbId, imdbId: this.imdbId,
season: episode['season_number'], season: episode.seasonNumber,
episode: episode['episode_number'], episode: episode.episodeNumber,
target: 'results_' ~ episode_id(episode['season_number'], episode['episode_number']), target: 'results_' ~ episode_id(episode.seasonNumber, episode.episodeNumber),
block: 'tvshow_results' block: 'tvshow_results'
}) }}" /> }) }}" />
</div> </div>

View File

@@ -12,20 +12,20 @@
</ul> </ul>
</div> </div>
</template> </template>
<small <span
class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white" class="py-1 px-1.5 mr-1 grow-0 font-bold bg-blue-600 rounded-lg text-center text-white"
data-action="mouseenter->popover#show mouseleave->popover#hide" data-action="mouseenter->popover#show mouseleave->popover#hide"
> >
exists exists
</small> </span>
</span> </span>
{% endif %} {% endif %}
{% if result.exists == false %} {% if result.exists == false %}
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white"
title="Media has not been downloaded yet."> title="Media has not been downloaded yet.">
missing missing
</small> </span>
{% endif %} {% endif %}
</template> </template>
</turbo-stream> </turbo-stream>

View File

@@ -20,7 +20,7 @@
<div class="w-full flex flex-col"> <div class="w-full flex flex-col">
<div class="mb-4 flex flex-row gap-2 justify-between"> <div class="mb-4 flex flex-row gap-2 justify-between">
<h3 class="text-xl font-medium leading-tight font-bold text-gray-50"> <h3 class="text-xl font-medium leading-tight font-bold text-gray-50">
{{ results.media.title }} ({{ results.media.year|date('Y') }}) {{ results.media.title }} ({{ results.media.episodeAirDate|date('Y') }})
</h3> </h3>
{% if results.media.mediaType == "tvshows" %} {% if results.media.mediaType == "tvshows" %}
@@ -51,29 +51,70 @@
</div> </div>
<p class="text-gray-50"> <p class="text-gray-50 mb-4">
{{ results.media.description }} {{ results.media.description }}
</p> </p>
<div>
{% if results.media.stars != null %}
<strong>Starring</strong>: {{ results.media.stars|join(', ') }} <br />
{% endif %}
{% if results.media.directors != null %}
<strong>Directors</strong>: {{ results.media.directors|join(', ') }} <br />
{% endif %}
{% if results.media.producers != null %}
<strong>Producers</strong>: {{ results.media.producers|join(', ') }} <br />
{% endif %}
{% if results.media.creators != null %}
<strong>Creators</strong>: {{ results.media.creators|join(', ') }} <br />
{% endif %}
{% if results.media.genres != null %}
<div id="genres" class="text-gray-50 my-4">
{% for genre in results.media.genres %}
<small class="px-2 py-1 border border-orange-500 rounded-full">{{ genre }}</small>
{% endfor %}
</div>
{% endif %}
</div>
{% if results.media.mediaType == "tvshows" %}
<div class="flex flex-row justify-start items-end grow text-xs">
<span class="py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-orange-500 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span>{{ results.media.numberSeasons }}</span> season(s)
</span>
<span class="py-1 px-1.5 mr-1 grow-0 bg-sky-700 rounded-lg text-white" title='"{{ results.media.title }}" first aired on {{ results.media.premiereDate|date(null, 'UTC') }}.'>
{{ results.media.premiereDate|date(null, 'UTC') }}
</span>
</div>
{% endif %}
{% if "movies" == results.media.mediaType %} {% if "movies" == results.media.mediaType %}
<div class="flex flex-row justify-start items-end grow"> <div class="flex flex-row justify-start items-end grow text-xs">
<span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white"> <span class="results-count-badge py-1 px-1.5 mr-1 grow-0 font-bold text-xs bg-green-600 rounded-lg hover:cursor-pointer hover:bg-green-700 text-white">
<span class="results-count-number" id="movie_results_count">-</span> results <span class="results-count-number" id="movie_results_count">-</span> results
</span> </span>
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-gray-700 rounded-lg font-normal text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate|date(null, 'UTC') }}
</small>
<twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', { <twig:Turbo:Frame id="meb_{{ results.media.imdbId }}" src="{{ path('api.library.search', {
title: results.media.title, title: results.media.title,
block: 'media_exists_badge', block: 'media_exists_badge',
target: "meb_" ~ results.media.imdbId target: "meb_" ~ results.media.imdbId
}) }}"> }) }}">
<small class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet."> <span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-rose-600 rounded-lg text-white" title="Movie has not been downloaded yet.">
missing missing
</small> </span>
</twig:Turbo:Frame> </twig:Turbo:Frame>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-sky-700 rounded-lg text-white" title="Release date {{ results.media.episodeAirDate }}">
{{ results.media.episodeAirDate|date('n/j/Y', 'UTC') }}
</span>
<span class="py-1 px-1.5 mr-1 grow-0 font-bold bg-orange-500 rounded-lg text-white" title="This movie has a runtime of {{ results.media.runtime }} minutes.">
{{ results.media.runtime }} minutes
</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>