fix: uses symfony de-normalizer to map tmdb data to objects

This commit is contained in:
2025-09-01 21:01:10 -05:00
parent 662e2600f6
commit b8b71fa5b3
9 changed files with 396 additions and 15 deletions

View File

@@ -46,6 +46,7 @@
"symfony/messenger": "7.3.*", "symfony/messenger": "7.3.*",
"symfony/notifier": "7.3.*", "symfony/notifier": "7.3.*",
"symfony/ntfy-notifier": "7.3.*", "symfony/ntfy-notifier": "7.3.*",
"symfony/object-mapper": "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.*",

75
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9ffd10f98137e8975de2c04ac2412ed5", "content-hash": "e055bbbbe5836c92bb147b6dbb1d1d46",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -7783,6 +7783,79 @@
], ],
"time": "2025-02-13T10:27:54+00:00" "time": "2025-02-13T10:27:54+00:00"
}, },
{
"name": "symfony/object-mapper",
"version": "v7.3.3",
"source": {
"type": "git",
"url": "https://github.com/symfony/object-mapper.git",
"reference": "f7f9833d9fcc8361239c1dae5495aa9e43ece0b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/object-mapper/zipball/f7f9833d9fcc8361239c1dae5495aa9e43ece0b5",
"reference": "f7f9833d9fcc8361239c1dae5495aa9e43ece0b5",
"shasum": ""
},
"require": {
"php": ">=8.2",
"psr/container": "^2.0"
},
"conflict": {
"symfony/property-access": "<7.2"
},
"require-dev": {
"symfony/property-access": "^7.2",
"symfony/var-exporter": "^7.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\ObjectMapper\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a way to map an object to another object",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/object-mapper/tree/v7.3.3"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-08-13T14:03:15+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v7.3.0", "version": "v7.3.0",

View File

@@ -67,6 +67,8 @@ 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

@@ -5,6 +5,7 @@ namespace App\Base\Framework\Controller;
use App\Monitor\Action\Command\MonitorTvShowCommand; use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use App\Tmdb\TmdbClient;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -16,20 +17,18 @@ use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController final class IndexController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly Tmdb $tmdb, private readonly TmdbClient $tmdb,
private readonly MonitorTvShowHandler $monitorTvShowHandler, private readonly MonitorTvShowHandler $monitorTvShowHandler,
) {} ) {}
#[Route('/', name: 'app_index')] #[Route('/', name: 'app_index')]
public function index(Request $request): Response public function index(Request $request): Response
{ {
/** @var User $user */
$user = $this->getUser();
return $this->render('index/index.html.twig', [ return $this->render('index/index.html.twig', [
'active_downloads' => $this->getUser()->getActiveDownloads(), 'active_downloads' => $this->getUser()->getActiveDownloads(),
'recent_downloads' => $this->getUser()->getDownloads(), 'recent_downloads' => $this->getUser()->getDownloads(),
'popular_movies' => $this->tmdb->popularMovies(1, 6), 'popular_movies' => $this->tmdb->popularMovies(),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6), 'popular_tvshows' => $this->tmdb->popularTvShows(),
]); ]);
} }

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Tmdb;
use Tmdb\Event\HydrationEvent;
class HydrationListener extends \Tmdb\Event\Listener\HydrationListener
{
public function hydrateSubject(HydrationEvent $event)
{
dump($event);
dd('hre');
}
}

View File

@@ -8,10 +8,12 @@ use App\ValueObject\ResultFactory;
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;
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Api\Find; use Tmdb\Api\Find;
use Tmdb\Client; use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent; use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\HydrationEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener; use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener; use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener; use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
@@ -62,8 +64,12 @@ class Tmdb
'uri_factory' => null, 'uri_factory' => null,
], ],
'hydration' => [ 'hydration' => [
'event_listener_handles_hydration' => false, 'event_listener_handles_hydration' => true,
'only_for_specified_models' => [] 'only_for_specified_models' => [
Movie::class,
Tv::class,
Tv\Episode::class,
]
] ]
] ]
); );
@@ -92,6 +98,9 @@ class Tmdb
$userAgentListener = new UserAgentRequestListener(); $userAgentListener = new UserAgentRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener); $this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener);
// $hydrationListener = new HydrationListener($this->eventDispatcher);
// $this->eventDispatcher->addListener(HydrationEvent::class, $hydrationListener);
$this->movieRepository = new MovieRepository($this->client); $this->movieRepository = new MovieRepository($this->client);
$this->tvRepository = new TvRepository($this->client); $this->tvRepository = new TvRepository($this->client);
} }
@@ -247,19 +256,21 @@ class Tmdb
return $series; return $series;
} }
public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 6) public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 20)
{ {
$repos = [ $repos = [
'movies' => $this->movieRepository, 'movies' => $this->movieRepository,
'tvshows' => $this->tvRepository, 'tvshows' => $this->tvRepository,
]; ];
$results = $repos[$mediaType]->getRecommendations($tmdbId, ['append_to_response' => 'external_ids']);
$results = $repos[$mediaType]->getRecommendations($tmdbId); $results = Map::from(array_values($results->toArray()))
return Map::from(array_values($results->toArray())) ->take($maxResults)
->slice(0, 6)
->map(function ($result) use ($mediaType) { ->map(function ($result) use ($mediaType) {
return $this->parseResult($result, $mediaType); return $this->parseResult($result, $mediaType);
})->toArray(); })->toArray();
dd($results);
} }
public function mediaDetails(string $id, string $type) public function mediaDetails(string $id, string $type)

166
src/Tmdb/TmdbClient.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
namespace App\Tmdb;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\ObjectMapper\ObjectMapper;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\HydrationEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Model\Movie;
use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Token\Api\ApiToken;
use Tmdb\Token\Api\BearerToken;
class TmdbClient
{
protected Client $client;
protected MovieRepository $movieRepository;
protected TvRepository $tvRepository;
protected ObjectMapper $objectMapper;
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
private readonly SerializerInterface $serializer,
private readonly CacheItemPoolInterface $cache,
private readonly EventDispatcherInterface $eventDispatcher,
#[Autowire(env: 'TMDB_API')] string $apiKey,
) {
$this->client = new Client(
[
/** @var ApiToken|BearerToken */
'api_token' => new BearerToken($apiKey),
'secure' => true,
'base_uri' => Client::TMDB_URI,
'event_dispatcher' => [
'adapter' => $this->eventDispatcher,
],
// We make use of PSR-17 and PSR-18 auto discovery to automatically guess these, but preferably set these explicitly.
'http' => [
'client' => null,
'request_factory' => null,
'response_factory' => null,
'stream_factory' => null,
'uri_factory' => null,
],
'hydration' => [
'event_listener_handles_hydration' => true,
'only_for_specified_models' => [
Movie::class,
Tv::class,
Tv\Episode::class,
]
]
]
);
/**
* Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
*/
$requestListener = new Psr6CachedRequestListener(
$this->client->getHttpClient(),
$this->eventDispatcher,
$cache,
$this->client->getHttpClient()->getPsr17StreamFactory(),
[]
);
$this->eventDispatcher->addListener(RequestEvent::class, $requestListener);
$apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $apiTokenListener);
$acceptJsonListener = new AcceptJsonRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $acceptJsonListener);
$jsonContentTypeListener = new ContentTypeJsonRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $jsonContentTypeListener);
$userAgentListener = new UserAgentRequestListener();
$this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener);
// $hydrationListener = new HydrationListener($this->eventDispatcher);
// $this->eventDispatcher->addListener(HydrationEvent::class, $hydrationListener);
$this->movieRepository = new MovieRepository($this->client);
$this->tvRepository = new TvRepository($this->client);
}
public function search() {}
public function find() {}
public function movieDetails() {}
public function tvshowDetails() {}
public function tvEpisodeDetails() {}
public function relatedMedia() {}
public function popularMovies(int $resultCount = 6): Map
{
$results = $this->movieRepository->getApi()->getPopular();
return Map::from($results['results'])->map(function ($result) {
$result['external_ids'] = $this->getExternalIds($result['id'], MediaType::Movie->value);
return $result;
})->filter(function ($result) {
return array_key_exists('imdb_id', $result['external_ids']);
})->map(function ($result) {
$result = $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => MediaType::Movie->value]);
return $result;
})->slice(0, $resultCount);
}
public function popularTvShows(int $resultCount = 6): Map
{
$results = $this->tvRepository->getApi()->getPopular();
return Map::from($results['results'])->map(function ($result) {
$result['external_ids'] = $this->getExternalIds($result['id'], MediaType::Movie->value);
return $result;
})->filter(function ($result) {
return array_key_exists('imdb_id', $result['external_ids']) && $result['external_ids']['imdb_id'] !== null && $result['external_ids']['imdb_id'] !== "";
})->map(function ($result) {
$result = $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => MediaType::TvShow->value]);
return $result;
})->slice(0, $resultCount);
}
public function getExternalIds(string $tmdbId, $mediaType)
{
try {
switch (MediaType::tryFrom($mediaType)->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 $externalIds;
}
}

View File

@@ -2,15 +2,22 @@
namespace App\Tmdb; namespace App\Tmdb;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class TmdbResult class TmdbResult
{ {
public function __construct( public function __construct(
#[SerializedPath('[external_ids][imdb_id]')]
public ?string $imdbId = "", public ?string $imdbId = "",
public ?string $tmdbId = "", #[SerializedPath('[id]')]
public ?int $tmdbId = null,
#[SerializedPath('[title]')]
public ?string $title = "", public ?string $title = "",
public ?string $poster = "", #[SerializedPath('[overview]')]
public ?string $description = "", public ?string $description = "",
public ?string $year = "", public ?string $poster = "",
public ?\DateTimeInterface $premiereDate = null,
public ?string $year = null,
public ?string $mediaType = "", public ?string $mediaType = "",
public ?array $episodes = null, public ?array $episodes = null,
public ?string $episodeAirDate = null, public ?string $episodeAirDate = null,

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Tmdb;
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',
'tvepisodes' => 'parseEpisode',
];
$denormalized = $this->normalizer->denormalize($data, TmdbResult::class, $format, $context);
return $this->{$parsers[$context['media_type']]}($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->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";
if (array_key_exists('episodes', $data)) {
$result->episodes = $data['episodes'];
}
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->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;
$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,
];
}
}