diff --git a/composer.json b/composer.json index 2c22e78..349015f 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "symfony/messenger": "7.3.*", "symfony/notifier": "7.3.*", "symfony/ntfy-notifier": "7.3.*", + "symfony/object-mapper": "7.3.*", "symfony/runtime": "7.3.*", "symfony/scheduler": "7.3.*", "symfony/security-bundle": "7.3.*", diff --git a/composer.lock b/composer.lock index 68f5b07..00212c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9ffd10f98137e8975de2c04ac2412ed5", + "content-hash": "e055bbbbe5836c92bb147b6dbb1d1d46", "packages": [ { "name": "1tomany/rich-bundle", @@ -7783,6 +7783,79 @@ ], "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", "version": "v7.3.0", diff --git a/config/services.yaml b/config/services.yaml index 4d9d2dc..7c744aa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -67,6 +67,8 @@ services: # please note that last definitions always *replace* previous ones App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader" + + # Session Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler: arguments: diff --git a/src/Base/Framework/Controller/IndexController.php b/src/Base/Framework/Controller/IndexController.php index b5abf67..b1a8598 100644 --- a/src/Base/Framework/Controller/IndexController.php +++ b/src/Base/Framework/Controller/IndexController.php @@ -5,6 +5,7 @@ namespace App\Base\Framework\Controller; use App\Monitor\Action\Command\MonitorTvShowCommand; use App\Monitor\Action\Handler\MonitorTvShowHandler; use App\Tmdb\Tmdb; +use App\Tmdb\TmdbClient; use App\User\Framework\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -16,20 +17,18 @@ use Symfony\Component\Routing\Attribute\Route; final class IndexController extends AbstractController { public function __construct( - private readonly Tmdb $tmdb, + private readonly TmdbClient $tmdb, private readonly MonitorTvShowHandler $monitorTvShowHandler, ) {} #[Route('/', name: 'app_index')] public function index(Request $request): Response { - /** @var User $user */ - $user = $this->getUser(); return $this->render('index/index.html.twig', [ 'active_downloads' => $this->getUser()->getActiveDownloads(), 'recent_downloads' => $this->getUser()->getDownloads(), - 'popular_movies' => $this->tmdb->popularMovies(1, 6), - 'popular_tvshows' => $this->tmdb->popularTvShows(1, 6), + 'popular_movies' => $this->tmdb->popularMovies(), + 'popular_tvshows' => $this->tmdb->popularTvShows(), ]); } diff --git a/src/Tmdb/HydrationListener.php b/src/Tmdb/HydrationListener.php new file mode 100644 index 0000000..f034a68 --- /dev/null +++ b/src/Tmdb/HydrationListener.php @@ -0,0 +1,14 @@ + null, ], 'hydration' => [ - 'event_listener_handles_hydration' => false, - 'only_for_specified_models' => [] + 'event_listener_handles_hydration' => true, + 'only_for_specified_models' => [ + Movie::class, + Tv::class, + Tv\Episode::class, + ] ] ] ); @@ -92,6 +98,9 @@ class Tmdb $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); } @@ -247,19 +256,21 @@ class Tmdb return $series; } - public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 6) + public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 20) { $repos = [ 'movies' => $this->movieRepository, 'tvshows' => $this->tvRepository, ]; + $results = $repos[$mediaType]->getRecommendations($tmdbId, ['append_to_response' => 'external_ids']); - $results = $repos[$mediaType]->getRecommendations($tmdbId); - return Map::from(array_values($results->toArray())) - ->slice(0, 6) + $results = Map::from(array_values($results->toArray())) + ->take($maxResults) ->map(function ($result) use ($mediaType) { return $this->parseResult($result, $mediaType); })->toArray(); + + dd($results); } public function mediaDetails(string $id, string $type) diff --git a/src/Tmdb/TmdbClient.php b/src/Tmdb/TmdbClient.php new file mode 100644 index 0000000..0734351 --- /dev/null +++ b/src/Tmdb/TmdbClient.php @@ -0,0 +1,166 @@ +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; + } +} diff --git a/src/Tmdb/TmdbResult.php b/src/Tmdb/TmdbResult.php index bad4fd6..97c293d 100644 --- a/src/Tmdb/TmdbResult.php +++ b/src/Tmdb/TmdbResult.php @@ -2,15 +2,22 @@ namespace App\Tmdb; +use Symfony\Component\Serializer\Attribute\SerializedPath; + class TmdbResult { public function __construct( + #[SerializedPath('[external_ids][imdb_id]')] public ?string $imdbId = "", - public ?string $tmdbId = "", + #[SerializedPath('[id]')] + public ?int $tmdbId = null, + #[SerializedPath('[title]')] public ?string $title = "", - public ?string $poster = "", + #[SerializedPath('[overview]')] public ?string $description = "", - public ?string $year = "", + public ?string $poster = "", + public ?\DateTimeInterface $premiereDate = null, + public ?string $year = null, public ?string $mediaType = "", public ?array $episodes = null, public ?string $episodeAirDate = null, diff --git a/src/Tmdb/TmdbResultDenormalizer.php b/src/Tmdb/TmdbResultDenormalizer.php new file mode 100644 index 0000000..6e7469d --- /dev/null +++ b/src/Tmdb/TmdbResultDenormalizer.php @@ -0,0 +1,108 @@ +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, + ]; + } +} \ No newline at end of file