diff --git a/assets/controllers/discover_media_results_controller.js b/assets/controllers/discover_media_results_controller.js new file mode 100644 index 0000000..63898ef --- /dev/null +++ b/assets/controllers/discover_media_results_controller.js @@ -0,0 +1,54 @@ +import { Controller } from '@hotwired/stimulus'; + +/* +* The following line makes this controller "lazy": it won't be downloaded until needed +* See https://symfony.com/bundles/StimulusBundle/current/index.html#lazy-stimulus-controllers +*/ + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static targets = ['poster', 'moreBtn', 'moreLink'] + + moreResultsClicks = 0; + + initialize() { + // Called once when the controller is first instantiated (per element) + + // Here you can initialize variables, create scoped callables for event + // listeners, instantiate external libraries, etc. + // this._fooBar = this.fooBar.bind(this) + } + + connect() { + // Called every time the controller is connected to the DOM + // (on page load, when it's added to the DOM, moved in the DOM, etc.) + + // Here you can add event listeners on the element or target elements, + // add or remove classes, attributes, dispatch custom events, etc. + // this.fooTarget.addEventListener('click', this._fooBar) + } + + // Add custom controller actions here + // fooBar() { this.fooTarget.classList.toggle(this.bazClass) } + + disconnect() { + // Called anytime its element is disconnected from the DOM + // (on page change, when it's removed from or moved in the DOM, etc.) + + // Here you should remove all event listeners added in "connect()" + // this.fooTarget.removeEventListener('click', this._fooBar) + } + + moreResults() { + const elems = this.posterTargets.filter(poster => poster.classList.contains('hidden')); + if (this.moreResultsClicks <= 2) { + elems.slice(0, 6).forEach(poster => poster.classList.remove('hidden')); + this.moreResultsClicks++; + + if (this.moreResultsClicks === 2) { + this.moreBtnTarget.classList.add('hidden'); + this.moreLinkTarget.classList.remove('hidden'); + } + } + } +} diff --git a/config/bundles.php b/config/bundles.php index 7502283..ab99b0d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -23,6 +23,6 @@ return [ SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], Drenso\OidcBundle\DrensoOidcBundle::class => ['all' => true], SpomkyLabs\PwaBundle\SpomkyLabsPwaBundle::class => ['all' => true], - Sentry\SentryBundle\SentryBundle::class => ['prod' => true], + Sentry\SentryBundle\SentryBundle::class => ['prod' => true, 'dev' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], ]; diff --git a/config/packages/sentry.yaml b/config/packages/sentry.yaml index ff6d855..339d285 100644 --- a/config/packages/sentry.yaml +++ b/config/packages/sentry.yaml @@ -1,41 +1,40 @@ -when@prod: - sentry: - register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry - register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK +sentry: + register_error_listener: true # Disables the ErrorListener to avoid duplicated log in sentry + register_error_handler: true # Disables the ErrorListener, ExceptionListener and FatalErrorListener integrations of the base PHP SDK - options: - release: 'torsearch@%app.version%' - enable_logs: true - traces_sample_rate: 1 - profiles_sample_rate: 1 - attach_stacktrace: true + options: + release: 'torsearch@%app.version%' + enable_logs: true + traces_sample_rate: 1 + profiles_sample_rate: 1 + attach_stacktrace: true - tracing: + tracing: + enabled: true + dbal: # DB queries + enabled: true + cache: # cache pools + enabled: true + twig: # templating engine enabled: true - dbal: # DB queries - enabled: true - cache: # cache pools - enabled: true - twig: # templating engine - enabled: true - services: - # (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler) - Sentry\Monolog\BreadcrumbHandler: - arguments: - - '@Sentry\State\HubInterface' - - !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs - monolog: - handlers: - # (Optionally) Register the breadcrumb handler as a Monolog handler - sentry_breadcrumbs: - type: service - name: sentry_breadcrumbs - id: Sentry\Monolog\BreadcrumbHandler - # Register the handler as a Monolog handler to capture messages as events - sentry: - type: sentry - level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events - hub_id: Sentry\State\HubInterface - fill_extra_context: true # Enables sending monolog context to Sentry - process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages +services: + # (Optionally) Configure the breadcrumb handler as a service (needed for the breadcrumb Monolog handler) + Sentry\Monolog\BreadcrumbHandler: + arguments: + - '@Sentry\State\HubInterface' + - !php/const Monolog\Logger::INFO # Configures the level of messages to capture as breadcrumbs +monolog: + handlers: + # (Optionally) Register the breadcrumb handler as a Monolog handler + sentry_breadcrumbs: + type: service + name: sentry_breadcrumbs + id: Sentry\Monolog\BreadcrumbHandler + # Register the handler as a Monolog handler to capture messages as events + sentry: + type: sentry + level: !php/const Monolog\Logger::ERROR # Configures the level of messages to capture as events + hub_id: Sentry\State\HubInterface + fill_extra_context: true # Enables sending monolog context to Sentry + process_psr_3_messages: false # Disables the resolution of PSR-3 placeholders in reported messages \ No newline at end of file diff --git a/config/routes.yaml b/config/routes.yaml index 2d44904..dfa5d85 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -6,6 +6,14 @@ controllersBase: defaults: schemes: [ 'https' ] +controllersDiscover: + resource: + path: ../src/Discover/Framework/Controller/ + namespace: App\Discover\Framework\Controller + type: attribute + defaults: + schemes: [ 'https' ] + controllersEventLog: resource: path: ../src/EventLog/Framework/Controller/ diff --git a/src/Discover/Framework/Controller/WebController.php b/src/Discover/Framework/Controller/WebController.php new file mode 100644 index 0000000..cd019be --- /dev/null +++ b/src/Discover/Framework/Controller/WebController.php @@ -0,0 +1,141 @@ +popularMovies(18); + $tvshows = $tmdb->popularTvShows(18); + + return $this->render('discover/index.html.twig', [ + 'movies' => $movies, + 'shows' => $tvshows, + ]); + } + + #[Route('/{mediaType}/{genreId?}', name: 'app.discover.browse')] + public function browse(string $mediaType, ?string $genreId, TmdbClient $tmdb) + { + if (null === $genreId) { + if (MediaType::tryFrom($mediaType) === null) { + return new Response(status: 404); + } + return $this->render('discover/browse.html.twig', [ + 'genres' => self::getGenres($mediaType), + 'media_type' => $mediaType, + ]); + } + + if (MediaType::tryFrom($mediaType) === null) { + return new Response(status: 404); + } + + if (TmdbMovieGenre::tryFrom($genreId) === null && + TmdbTvShowGenre::tryFrom($genreId) === null + ) { + return new Response(status: 404); + } + + $results = match ($mediaType) { + MediaType::Movie->value => $tmdb->discoverMovies( + [TmdbMovieGenre::from($genreId)->value] + ), + MediaType::TvShow->value => $tmdb->discoverTvShows( + [TmdbTvShowGenre::from($genreId)->value] + ), + }; + + return $this->render('discover/browse_genre.html.twig', [ + 'media' => $results, + 'genre' => TmdbMovieGenre::from($genreId)->name, + 'genre_id' => $genreId, + 'media_type' => $mediaType, + ]); + } + + #[Route('/{mediaType}/{genreId}', name: 'app.discover.browse_genre')] + public function browseGenre(string $mediaType, string $genreId, TmdbClient $tmdb) + { + if (MediaType::tryFrom($mediaType) === null) { + return new Response(status: 404); + } + + if (TmdbMovieGenre::tryFrom($genreId) === null && + TmdbTvShowGenre::tryFrom($genreId) === null + ) { + return new Response(status: 404); + } + + $results = match ($mediaType) { + MediaType::Movie->value => $tmdb->discoverMovies( + [TmdbMovieGenre::from($genreId)->value] + ), + MediaType::TvShow->value => $tmdb->discoverTvShows( + [TmdbTvShowGenre::from($genreId)->value] + ), + }; + + return $this->render('discover/browse.html.twig', [ + 'media' => $results, + 'media_type' => $mediaType, + ]); + } + + private static function getGenres(string $mediaType): array + { + return match ($mediaType) { + MediaType::Movie->value => [ + 'Action' => TmdbMovieGenre::Action->value, + 'Adventure' => TmdbMovieGenre::Adventure->value, + 'Animation' => TmdbMovieGenre::Animation->value, + 'Comedy' => TmdbMovieGenre::Comedy->value, + 'Crime' => TmdbMovieGenre::Crime->value, + 'Documentary' => TmdbMovieGenre::Documentary->value, + 'Drama' => TmdbMovieGenre::Drama->value, + 'Family' => TmdbMovieGenre::Family->value, + 'Fantasy' => TmdbMovieGenre::Fantasy->value, + 'History' => TmdbMovieGenre::History->value, + 'Horror' => TmdbMovieGenre::Horror->value, + 'Music' => TmdbMovieGenre::Music->value, + 'Mystery' => TmdbMovieGenre::Mystery->value, + 'Romance' => TmdbMovieGenre::Romance->value, + 'Science Fiction' => TmdbMovieGenre::ScienceFiction->value, + 'TV Movie' => TmdbMovieGenre::TvMovie->value, + 'Thriller' => TmdbMovieGenre::Thriller->value, + 'War' => TmdbMovieGenre::War->value, + 'Western' => TmdbMovieGenre::Western->value, + ], + MediaType::TvShow->value => [ + 'Action & Adventure' => TmdbTvShowGenre::ActionAndAdventure->value, + 'Animation' => TmdbTvShowGenre::Animation->value, + 'Comedy' => TmdbTvShowGenre::Comedy->value, + 'Crime' => TmdbTvShowGenre::Crime->value, + 'Documentary' => TmdbTvShowGenre::Documentary->value, + 'Drama' => TmdbTvShowGenre::Drama->value, + 'Family' => TmdbTvShowGenre::Family->value, + 'Kids' => TmdbTvShowGenre::Kids->value, + 'Mystery' => TmdbTvShowGenre::Mystery->value, + 'News' => TmdbTvShowGenre::News->value, + 'Reality' => TmdbTvShowGenre::Reality->value, + 'Sci-Fi & Fantasy' => TmdbTvShowGenre::SciFiAndFantasy->value, + 'Soap' => TmdbTvShowGenre::Soap->value, + 'Talk' => TmdbTvShowGenre::Talk->value, + 'War & Politics' => TmdbTvShowGenre::WarAndPolitics->value, + 'Western' => TmdbTvShowGenre::Western->value, + ], + default => [], + }; + } +} diff --git a/src/Tmdb/Dto/WatchProviderDto.php b/src/Tmdb/Dto/WatchProviderDto.php new file mode 100644 index 0000000..1489215 --- /dev/null +++ b/src/Tmdb/Dto/WatchProviderDto.php @@ -0,0 +1,22 @@ + self::BASE_LOGO_PATH . $value; + } + public string $url; +} diff --git a/src/Tmdb/Framework/Controller/ApiController.php b/src/Tmdb/Framework/Controller/ApiController.php index a0c6620..45f0abd 100644 --- a/src/Tmdb/Framework/Controller/ApiController.php +++ b/src/Tmdb/Framework/Controller/ApiController.php @@ -2,13 +2,18 @@ namespace App\Tmdb\Framework\Controller; +use App\Base\Enum\MediaType; use App\Base\Util\ImdbMatcher; +use App\Library\Action\Result\LibrarySearchResult; use App\Tmdb\TmdbClient; +use App\Tmdb\TmdbMovieGenre; use App\Tmdb\TmdbResult; +use App\Tmdb\TmdbTvShowGenre; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\UX\Turbo\TurboBundle; class ApiController extends AbstractController { @@ -47,4 +52,50 @@ class ApiController extends AbstractController 'results' => $results, ]); } + + #[Route('/api/tmdb/watch-providers/{mediaType}/{tmdbId}', name: 'api.tmdb.watch_providers', methods: ['GET'])] + public function watchProviders(string $mediaType, string $tmdbId, Request $request, TmdbClient $tmdb) + { + $result = $tmdb->watchProviders($tmdbId, $mediaType); + if ($request->headers->get('Turbo-Frame')) { + return $this->sendFragmentResponse(['providers' => $result], $request); + } + + return $this->json($result); + } + + #[Route('/api/tmdb/genre/{mediaType}/{genreId}', name: 'api.tmdb.genre', methods: ['GET'])] + public function genreResults(string $mediaType, string $genreId, Request $request, TmdbClient $tmdb) + { + $genre = TmdbMovieGenre::from($genreId); + $results['media_type'] = $mediaType; + $results['genre'] = $genre->name; + $results['genre_id'] = $genre->value; + $results['media'] = match($mediaType) { + MediaType::Movie->value => $tmdb->discoverMovies( + [TmdbMovieGenre::from($genre->value)], + ), + MediaType::TvShow->value => $tmdb->discoverTvShows( + [TmdbTvShowGenre::from($genreId)] + ) + }; + if ($request->headers->get('Turbo-Frame')) { + return $this->sendFragmentResponse(['result' => $results], $request); + } + + return $this->json($results); + } + + private function sendFragmentResponse(mixed $result, Request $request): Response + { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->renderBlock( + 'discover/fragments.html.twig', + $request->query->get('block'), + [ + 'result' => $result, + 'target' => $request->query->get('target') + ] + ); + } } diff --git a/src/Tmdb/Framework/Serializer/TmdbResultDenormalizer.php b/src/Tmdb/Framework/Serializer/TmdbResultDenormalizer.php index 784182d..90b06df 100644 --- a/src/Tmdb/Framework/Serializer/TmdbResultDenormalizer.php +++ b/src/Tmdb/Framework/Serializer/TmdbResultDenormalizer.php @@ -7,6 +7,7 @@ use App\Base\Enum\MediaType; use App\Tmdb\Dto\CastMemberDto; use App\Tmdb\Dto\CrewMemberDto; use App\Tmdb\Dto\GenreDto; +use App\Tmdb\Dto\WatchProviderDto; use App\Tmdb\TmdbResult; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -28,6 +29,7 @@ class TmdbResultDenormalizer implements DenormalizerInterface $result->directors = $this->getDirectors($data); $result->producers = $this->getProducers($data); $result->creators = $this->getCreators($data); + $result->watchProviders = $this->getWatchProviders($data); return $result; } @@ -87,6 +89,17 @@ class TmdbResultDenormalizer implements DenormalizerInterface ->toArray(); } + public function getWatchProviders(array $data): ?array + { + if (!array_key_exists('watch/providers', $data)) { + return null; + } + // ToDo: Make region configurable + return Map::from($data['watch/providers']['results']['US']['flatrate']) + ->map(fn($item) => $this->normalizer->denormalize($item, WatchProviderDto::class)) + ->toArray(); + } + public function supportsDenormalization( mixed $data, string $type, diff --git a/src/Tmdb/TmdbClient.php b/src/Tmdb/TmdbClient.php index 626d39b..69df8da 100644 --- a/src/Tmdb/TmdbClient.php +++ b/src/Tmdb/TmdbClient.php @@ -6,9 +6,11 @@ use Aimeos\Map; use App\Base\Enum\MediaType; use App\Base\Util\ImdbMatcher; use App\Tmdb\Dto\TmdbEpisodeDto; +use App\Tmdb\Dto\WatchProviderDto; use Psr\Cache\CacheItemPoolInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\Serializer\SerializerInterface; use Tmdb\Api\Find; use Tmdb\Client; @@ -31,7 +33,7 @@ use Tmdb\Token\Api\BearerToken; class TmdbClient { const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500"; - const APPEND_TO_RESPONSE = 'external_ids,credits,watch/providers'; + const APPEND_TO_RESPONSE = 'external_ids,credits'; protected Client $client; protected MovieRepository $movieRepository; @@ -139,6 +141,50 @@ class TmdbClient return $this->parseListOfResults($results); } + public function discoverMovies(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map + { + if (!empty($genres) && $genres[0] instanceof TmdbMovieGenre) { + $genres = array_map(fn ($genre) => $genre->value, $genres); + } + $results = $this->discoverRepository->getApi()->discoverMovies([ + 'page' => $page, + 'page_size' => $pageSize, + 'with_genres' => implode(',', $genres), + 'with_original_language' => $this->originalLanguage, + 'append_to_response' => static::APPEND_TO_RESPONSE, + ]); + $results['results'] = Map::from($results['results'])->map(function ($result) { + $result['media_type'] = MediaType::Movie->value; + return $result; + }); + + return $this->parseListOfResults( + $results, + ); + } + + public function discoverTvshows(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map + { + if (!empty($genres) && $genres[0] instanceof TmdbTvShowGenre) { + $genres = array_map(fn ($genre) => $genre->value, $genres); + } + $results = $this->discoverRepository->getApi()->discoverTv([ + 'page' => $page, + 'page_size' => $pageSize, + 'with_genres' => implode(',', $genres), + 'with_original_language' => $this->originalLanguage, + 'append_to_response' => static::APPEND_TO_RESPONSE, + ]); + $results['results'] = Map::from($results['results'])->map(function ($result) { + $result['media_type'] = MediaType::TvShow->value; + return $result; + }); + + return $this->parseListOfResults( + $results, + ); + } + public function movieDetails(string $imdbId): ?TmdbResult { $tmdbId = $this->findByImdbId($imdbId)['id']; @@ -201,6 +247,14 @@ class TmdbClient ); } + public function watchProviders(string $tmdbId, string $mediaType): Map + { + $results = $this->repos[$mediaType]->getApi()->getWatchProviders($tmdbId)['results']['US']['flatrate']; + return Map::from($results)->map(function ($result) { + return $this->serializer->denormalize($result, WatchProviderDto::class); + }); + } + public function popularMovies(int $resultCount = 6): Map { $results = $this->discoverRepository->getApi()->discoverMovies([ diff --git a/src/Tmdb/TmdbMovieGenre.php b/src/Tmdb/TmdbMovieGenre.php new file mode 100644 index 0000000..48e7404 --- /dev/null +++ b/src/Tmdb/TmdbMovieGenre.php @@ -0,0 +1,26 @@ + {{ pwa() }} - {% block title %}Welcome!{% endblock %} + {% block title %}Torsearch{% endblock %} {% block stylesheets %} diff --git a/templates/components/NavBar.html.twig b/templates/components/NavBar.html.twig index 05abb30..854d8d1 100644 --- a/templates/components/NavBar.html.twig +++ b/templates/components/NavBar.html.twig @@ -29,6 +29,15 @@ +
  • + + Discover + +
  • +
  • diff --git a/templates/components/Poster.html.twig b/templates/components/Poster.html.twig index 456ef8c..769760a 100644 --- a/templates/components/Poster.html.twig +++ b/templates/components/Poster.html.twig @@ -1,14 +1,21 @@ + {% if image != null and image != "https://image.tmdb.org/t/p/w500" %} + + + + {% else %} +
    + +
    + {% endif %} + - - - -

    {{ title }}

    +

    {{ title }}

    diff --git a/templates/components/PosterContainer.html.twig b/templates/components/PosterContainer.html.twig new file mode 100644 index 0000000..c015f8f --- /dev/null +++ b/templates/components/PosterContainer.html.twig @@ -0,0 +1,30 @@ + +
    + {% for i in range(0, media|length - 1) %} + {% if i > 5 and tease is true %} + {% set class_list = "hidden" %} + {% else %} + {% set class_list = "" %} + {% endif %} + + {% set poster = media[i] %} + + {% endfor %} +
    + + {% if tease == true %} +
    + + +
    + {% endif %} + diff --git a/templates/components/SearchResult.html.twig b/templates/components/SearchResult.html.twig index 19a37e9..a5a4de4 100644 --- a/templates/components/SearchResult.html.twig +++ b/templates/components/SearchResult.html.twig @@ -1,7 +1,7 @@
    {% if poster != null and poster != "https://image.tmdb.org/t/p/w500" %} - + {% else %}
    diff --git a/templates/discover/browse.html.twig b/templates/discover/browse.html.twig new file mode 100644 index 0000000..988b5d2 --- /dev/null +++ b/templates/discover/browse.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} +{% block title %}Discover {{ media_type|capitalize }} — {{ parent() }}{% endblock %} +{% block h2 %}Discover {{ media_type|capitalize }}{% endblock %} + +{% block body %} +
    + {% for genreTitle, genreId in genres %} + + + {% endfor %} +
    +{% endblock %} diff --git a/templates/discover/browse_genre.html.twig b/templates/discover/browse_genre.html.twig new file mode 100644 index 0000000..40f32c6 --- /dev/null +++ b/templates/discover/browse_genre.html.twig @@ -0,0 +1,11 @@ +{% extends 'base.html.twig' %} +{% block title %}Discover {{ genre }} {{ media_type|capitalize }} — {{ parent() }}{% endblock %} +{% block h2 %}Discover {{ genre }} {{ media_type|capitalize }}{% endblock %} + +{% block body %} +
    + + + +
    +{% endblock %} diff --git a/templates/discover/fragments.html.twig b/templates/discover/fragments.html.twig new file mode 100644 index 0000000..c5bd451 --- /dev/null +++ b/templates/discover/fragments.html.twig @@ -0,0 +1,25 @@ +{% block watch_providers %} + {% if result.providers %} + + + + {% endif %} +{% endblock %} + +{% block genre_results %} + + + +{% endblock %} diff --git a/templates/discover/index.html.twig b/templates/discover/index.html.twig index e69de29..cbaccfb 100644 --- a/templates/discover/index.html.twig +++ b/templates/discover/index.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block title %}Discover — {{ parent() }}{% endblock %} + +{% block h2 %}Discover New Media{% endblock %} + +{% block body %} +
    + + + + + + + +
    +{% endblock %} diff --git a/templates/search/result.html.twig b/templates/search/result.html.twig index e54888e..46a54de 100644 --- a/templates/search/result.html.twig +++ b/templates/search/result.html.twig @@ -76,48 +76,57 @@ {% if results.media.genres != null %}
    {% for genre in results.media.genres %} - {{ genre }} + {{ genre }} {% endfor %}
    {% endif %}
    - {% if results.media.mediaType == "tvshows" %} -
    - - {{ results.media.numberSeasons }} season(s) - - - {{ results.media.premiereDate|date(null, 'UTC') }} - -
    - {% endif %} +
    + {% if results.media.mediaType == "tvshows" %} +
    + + {{ results.media.numberSeasons }} season(s) + + + {{ results.media.premiereDate|date(null, 'UTC') }} + +
    + {% endif %} - {% if "movies" == results.media.mediaType %} -
    - - - results - + {% if "movies" == results.media.mediaType %} +
    + + - results + - + + missing + + + + + {{ results.media.premiereDate|date('n/j/Y', 'UTC') }} + + + + {{ results.media.runtime }} minutes + +
    + {% endif %} + - - missing - - - - {{ results.media.premiereDate|date('n/j/Y', 'UTC') }} - - - - {{ results.media.runtime }} minutes -
    - {% endif %}