Compare commits

..

3 Commits

Author SHA1 Message Date
f9ddd70668 wip: apparently torrentio doesn't need configuration 2025-08-31 09:06:29 -05:00
33bfe40b11 fix: increases columns size 2025-08-31 00:23:27 -05:00
f3a2f35571 fix: adds favicon 2025-08-31 00:20:35 -05:00
43 changed files with 1132 additions and 604 deletions

View File

@@ -168,6 +168,7 @@ dialog[data-dialog-target="dialog"][closing] {
background: transparent;
}
form[name="torrentio_preferences_form"],
#filter {
.ts-wrapper {
box-shadow: none !important;

View File

@@ -46,7 +46,6 @@
"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.*",

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",
"This file is @generated automatically"
],
"content-hash": "e055bbbbe5836c92bb147b6dbb1d1d46",
"content-hash": "9ffd10f98137e8975de2c04ac2412ed5",
"packages": [
{
"name": "1tomany/rich-bundle",
@@ -7783,79 +7783,6 @@
],
"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",

View File

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

View File

@@ -6,5 +6,4 @@ enum MediaType: string
{
case Movie = 'movies';
case TvShow = 'tvshows';
case TvEpisode = 'tvepisode';
}

View File

@@ -4,7 +4,6 @@ namespace App\Base\Framework\Command;
use App\User\Framework\Entity\Preference;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferenceOptionRepository;
use App\User\Framework\Repository\PreferencesRepository;
use App\User\Framework\Repository\UserRepository;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -139,9 +138,16 @@ class SeedDatabaseCommand extends Command
'id' => 'enable_ical_up_ep',
'name' => 'Enable a publicly available iCal calendar?',
'description' => 'Enable a publicly accessible iCal URL for your upcoming episodes.',
'enabled' => false,
'enabled' => true,
'type' => 'calendar'
],
[
'id' => 'torrentio_url',
'name' => 'A custom Torrentio URL',
'description' => 'If you want to use a custom Torrentio URL, enter it here. Otherwise, leave it blank to use the one provided as an environment variable.',
'enabled' => true,
'type' => 'torrentio'
],
];
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Base\Framework\Controller;
use App\Monitor\Action\Command\MonitorTvShowCommand;
use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use App\User\Framework\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -14,17 +16,20 @@ use Symfony\Component\Routing\Attribute\Route;
final class IndexController extends AbstractController
{
public function __construct(
private readonly TmdbClient $tmdb,
private readonly Tmdb $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(),
'popular_tvshows' => $this->tmdb->popularTvShows(),
'popular_movies' => $this->tmdb->popularMovies(1, 6),
'popular_tvshows' => $this->tmdb->popularTvShows(1, 6),
]);
}

View File

@@ -6,6 +6,6 @@ class ImdbMatcher
{
public static function isMatch(string $imdbId): bool
{
return preg_match('/^tt\d{7,20}$/', $imdbId);
return preg_match('/^tt\d{7}$/', $imdbId);
}
}

View File

@@ -3,14 +3,13 @@
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\DownloadSeasonCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Action\Result\DownloadSeasonResult;
use App\Download\DownloadOptionEvaluator;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
@@ -28,7 +27,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
public function __construct(
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private TmdbClient $tmdb,
private Tmdb $tmdb,
private MessageBusInterface $bus,
private DownloadOptionEvaluator $downloadOptionEvaluator,
private GetTvShowOptionsHandler $getTvShowOptionsHandler,
@@ -37,8 +36,7 @@ readonly class DownloadSeasonHandler implements HandlerInterface
public function handle(CommandInterface $command): ResultInterface
{
$series = $this->tmdb->tvshowDetails($command->imdbId);
$series = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
$this->logger->info('> [DownloadTvSeasonHandler] Executing DownloadTvSeasonHandler for "' . $series->title . '" season ' . $command->season);
$episodesInSeason = Map::from($series->episodes[$command->season]);

View File

@@ -9,7 +9,7 @@ use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Action\Command\MonitorMovieCommand;
use App\Monitor\Action\Result\MonitorTvEpisodeResult;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetTvShowOptionsHandler;
use App\User\Dto\UserPreferencesFactory;
@@ -32,7 +32,7 @@ readonly class MonitorTvEpisodeHandler implements HandlerInterface
private MessageBusInterface $bus,
private LoggerInterface $logger,
private MonitorRepository $monitorRepository,
private TmdbClient $tmdb,
private Tmdb $tmdb,
private DownloadRepository $downloadRepository,
) {}

View File

@@ -9,7 +9,7 @@ use App\Monitor\Action\Command\MonitorTvSeasonCommand;
use App\Monitor\Action\Result\MonitorTvSeasonResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN;
@@ -26,7 +26,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
private EntityManagerInterface $entityManager,
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private TmdbClient $tmdb,
private Tmdb $tmdb,
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
) {}
@@ -50,7 +50,7 @@ readonly class MonitorTvSeasonHandler implements HandlerInterface
// Compare against list from TMDB
$episodesInSeason = Map::from(
$this->tmdb->tvshowDetails($monitor->getImdbId())->episodes[$monitor->getSeason()]
$this->tmdb->tvDetails($monitor->getTmdbId())->episodes[$monitor->getSeason()]
)->rekey(fn($episode) => $episode['episode_number']);
$this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($episodesInSeason) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle());

View File

@@ -9,8 +9,9 @@ use App\Monitor\Action\Command\MonitorTvEpisodeCommand;
use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use App\Base\Util\PTN;
@@ -28,7 +29,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
private MonitorTvEpisodeHandler $monitorTvEpisodeHandler,
private MediaFiles $mediaFiles,
private LoggerInterface $logger,
private TmdbClient $tmdb,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
@@ -52,7 +53,7 @@ readonly class MonitorTvShowHandler implements HandlerInterface
// Compare against list from TMDB
$episodesInShow = Map::from(
$this->tmdb->tvshowDetails($monitor->getImdbId())->episodes
$this->tmdb->tvDetails($monitor->getTmdbId())->episodes
)->flat(1);
$this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes for title: ' . $monitor->getTitle());

View File

@@ -2,11 +2,9 @@
namespace App\Search\Action\Handler;
use App\Base\Enum\MediaType;
use App\Search\Action\Command\GetMediaInfoCommand;
use App\Search\Action\Result\GetMediaInfoResult;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use App\Tmdb\Tmdb;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -15,30 +13,14 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetMediaInfoHandler implements HandlerInterface
{
public function __construct(
private readonly TmdbClient $tmdb,
private readonly Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$handlers = [
MediaType::Movie->value => 'getMovieDetails',
MediaType::TvShow->value => 'getTvshowDetails',
];
$handler = $handlers[$command->mediaType];
$media = $this->$handler($command);
$media = $this->tmdb->mediaDetails($command->imdbId, $command->mediaType);
$relatedMedia = $this->tmdb->relatedMedia($media->tmdbId, $command->mediaType);
return new GetMediaInfoResult($media, $relatedMedia, $command->season, $command->episode);
}
private function getMovieDetails(CommandInterface $command): TmdbResult
{
return $this->tmdb->movieDetails($command->imdbId);
}
private function getTvshowDetails(CommandInterface $command): TmdbResult
{
$media = $this->tmdb->tvshowDetails($command->imdbId);
return $media;
}
}

View File

@@ -2,10 +2,10 @@
namespace App\Search\Action\Handler;
use App\Base\Util\ImdbMatcher;
use App\Search\Action\Result\RedirectToMediaResult;
use App\Search\Action\Result\SearchResult;
use App\Tmdb\TmdbClient;
use App\Tmdb\TmdbResult;
use App\Tmdb\Tmdb;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -14,13 +14,13 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class SearchHandler implements HandlerInterface
{
public function __construct(
private TmdbClient $tmdb,
private Tmdb $tmdb,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$result = $this->tmdb->search($command->term);
if ($result instanceof TmdbResult) {
if (ImdbMatcher::isMatch($command->term)) {
$result = $this->tmdb->findByImdbId($command->term);
return new RedirectToMediaResult(
imdbId: $result->imdbId,
mediaType: $result->mediaType,

View File

@@ -2,7 +2,6 @@
namespace App\Search\Action\Result;
use Aimeos\Map;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -11,7 +10,7 @@ class GetMediaInfoResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public Map|array $relatedMedia,
public array $relatedMedia,
public ?int $season,
public ?int $episode,
) {}

View File

@@ -2,7 +2,6 @@
namespace App\Search\Action\Result;
use Aimeos\Map;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<SearchResult> */
@@ -10,6 +9,6 @@ class SearchResult implements ResultInterface
{
public function __construct(
public string $term = "",
public Map|array $results = [],
public array $results = []
) {}
}

View File

@@ -3,7 +3,7 @@
namespace App\Tmdb\Framework\Controller;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use App\Tmdb\TmdbResult;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
#[Route('/api/tmdb/ajax-search', name: 'api_tmdb_ajax_search', methods: ['GET'])]
public function test(TmdbClient $tmdb, Request $request): Response
public function test(Tmdb $tmdb, Request $request): Response
{
$results = [];
@@ -22,7 +22,7 @@ class ApiController extends AbstractController
if (null !== $term) {
if (ImdbMatcher::isMatch($term)) {
$tmdbResult = $tmdb->search($term);
$tmdbResult = $tmdb->findByImdbId($term);
$results = [
[
'data' => $tmdbResult,

438
src/Tmdb/Tmdb.php Normal file
View File

@@ -0,0 +1,438 @@
<?php
namespace App\Tmdb;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\ValueObject\ResultFactory;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
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\Search\SearchQuery\KeywordSearchQuery;
use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Repository\TvSeasonRepository;
use Tmdb\Token\Api\ApiToken;
use Tmdb\Token\Api\BearerToken;
class Tmdb
{
protected Client $client;
protected MovieRepository $movieRepository;
protected TvRepository $tvRepository;
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
public function __construct(
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' => false,
'only_for_specified_models' => []
]
]
);
/**
* 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);
$this->movieRepository = new MovieRepository($this->client);
$this->tvRepository = new TvRepository($this->client);
}
public function popularMovies(int $page = 1, ?int $limit = null)
{
$movies = $this->movieRepository->getPopular(['page' => $page]);
$movies = $movies->map(function ($movie) use ($movies) {
return $this->parseResult($movies[$movie], "movie");
});
$movies = Map::from($movies->toArray())->filter(function ($movie) {
return $movie !== null
&& $movie->imdbId !== null
&& $movie->tmdbId !== null
&& $movie->title !== null
&& $movie->poster !== null
&& $movie->description !== null
&& $movie->mediaType !== null;
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;
}
public function popularTvShows(int $page = 1, ?int $limit = null)
{
$movies = $this->tvRepository->getPopular(['page' => $page]);
$movies = $movies->map(function ($movie) use ($movies) {
return $this->parseResult($movies[$movie], "movie");
});
$movies = Map::from($movies->toArray())->filter(function ($movie) {
return $movie !== null
&& $movie->imdbId !== null
&& $movie->tmdbId !== null
&& $movie->title !== null
&& $movie->poster !== null
&& $movie->description !== null
&& $movie->mediaType !== null;
});
$movies = array_values($movies->toArray());
if (null !== $limit) {
$movies = array_slice($movies, 0, $limit);
}
return $movies;
}
public function search(string $term, int $page = 1)
{
$searchRepository = new SearchRepository($this->client);
$searchResults = $searchRepository->searchMulti($term, new KeywordSearchQuery(['page' => $page]));
$results = [];
foreach ($searchResults as $result) {
if (!$result instanceof Movie && !$result instanceof Tv) {
continue;
}
$results[] = $this->parseResult($result);
}
$results = array_filter($results, fn ($result) => null !== $result->imdbId);
return $results;
}
public function find(string $id)
{
$finder = new Find($this->client);
$result = $finder->findBy($id, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
return $result['movie_results'][0]['id'];
} elseif (count($result['tv_results']) > 0) {
return $result['tv_results'][0]['id'];
} elseif (count($result['tv_episode_results']) > 0) {
return $result['tv_episode_results'][0]['show_id'];
}
throw new \Exception("No results found for $id");
}
public function findByImdbId(string $imdbId)
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
$result = $result['movie_results'][0];
$mediaType = MediaType::Movie->value;
} elseif (count($result['tv_results']) > 0) {
$result = $result['tv_results'][0];
$mediaType = MediaType::TvShow->value;
} elseif (count($result['tv_episode_results']) > 0) {
$result = $result['tv_episode_results'][0];
$mediaType = MediaType::TvShow->value;
}
$result['media_type'] = $mediaType;
$result = $this->mediaDetails($imdbId, $result['media_type']);
return $result;
}
public function movieDetails(string $id)
{
$client = new MovieRepository($this->client);
$details = $client->getApi()->getMovie($id, ['append_to_response' => 'external_ids']);
return $this->parseResult($details, "movie");
}
public function tvDetails(string $id)
{
$client = new TvRepository($this->client);
$details = $client->getApi()->getTvshow($id, ['append_to_response' => 'external_ids,seasons']);
$details = $this->getEpisodesFromSeries($details);
return $this->parseResult($details, "tvshow");
}
public function episodeDetails(string $id, string $season, string $episode)
{
$client = new TvEpisodeRepository($this->client);
$result = $client->getApi()->getEpisode($id, $season, $episode, ['append_to_response' => 'external_ids']);
return $this->parseResult($result, "episode");
}
public function getEpisodesFromSeries(array $series)
{
$client = new TvSeasonRepository($this->client);
foreach ($series['seasons'] as $season) {
if ($season['episode_count'] <= 0 || $season['name'] === 'Specials') {
continue;
}
$series['episodes'][$season['season_number']] = Map::from(
$client->getApi()->getSeason($series['id'], $season['season_number'])['episodes']
)->map(function ($data) {
$data['poster'] = (null !== $data['still_path']) ? self::POSTER_IMG_PATH . $data['still_path'] : null;
return $data;
})->toArray();
}
return $series;
}
public function relatedMedia(string $tmdbId, string $mediaType, int $maxResults = 6)
{
$repos = [
'movies' => $this->movieRepository,
'tvshows' => $this->tvRepository,
];
$results = $repos[$mediaType]->getRecommendations($tmdbId);
return Map::from(array_values($results->toArray()))
->slice(0, 6)
->map(function ($result) use ($mediaType) {
return $this->parseResult($result, $mediaType);
})->toArray();
}
public function mediaDetails(string $id, string $type)
{
$id = $this->find($id);
if ($type === "movies") {
return $this->movieDetails($id);
} else {
return $this->tvDetails($id);
}
}
private function parseResult($result, $mediaType = null)
{
if (is_array($result)) {
return $this->parseFromArray($result, $mediaType);
} else {
return $this->parseFromObject($result);
}
}
private function parseFromArray($data, $mediaType)
{
if (null === $mediaType) {
throw new \Exception("A media type must be set when parsing from an array.");
}
if ($mediaType === 'movie') {
$result = $this->parseMovie($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'tvshow') {
$result = $this->parseTvShow($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'episode') {
$result = $this->parseEpisode($data, self::POSTER_IMG_PATH);
}
return $result;
}
private function parseTvShow(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['first_air_date'], ['', null,])) {
$airDate = (new \DateTime($data['first_air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: $airDate,
mediaType: "tvshows",
episodes: $data['episodes'],
);
}
private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['air_date'], ['', null,])) {
$airDate = (new \DateTime($data['air_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['name'],
poster: (null !== $data['still_path']) ? $posterBasePath . $data['still_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['air_date']))->format('Y'),
mediaType: "tvshows",
episodes: null,
episodeAirDate: $airDate,
);
}
private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
if (!in_array($data['release_date'], ['', null,])) {
$airDate = (new \DateTime($data['release_date']))->format('Y-m-d');
} else {
$airDate = null;
}
return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'],
title: $data['title'],
poster: (null !== $data['poster_path']) ? $posterBasePath . $data['poster_path'] : null,
description: $data['overview'],
year: (new \DateTime($data['release_date']))->format('Y'),
mediaType: "movies",
episodeAirDate: $airDate,
);
}
private function parseFromObject($result): TmdbResult
{
$mediaType = $result instanceof Movie ? MediaType::Movie->value : MediaType::TvShow->value;
$tmdbResult = new TmdbResult();
$tmdbResult->mediaType = $mediaType;
$tmdbResult->tmdbId = $result->getId();
$tmdbResult->imdbId = $this->getImdbId($result->getId(), $mediaType);
$tmdbResult->title = $this->getTitle($result, $mediaType);
$tmdbResult->poster = self::POSTER_IMG_PATH . $result->getPosterImage();
$tmdbResult->year = $this->getReleaseDate($result, $mediaType);
$tmdbResult->description = $result->getOverview();
return $tmdbResult;
}
public function getImdbId(string $tmdbId, $mediaType)
{
$externalIds = $this->cache->get("tmdb.externalIds.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return $this->movieRepository->getExternalIds($tmdbId);
case MediaType::TvShow->value:
return $this->tvRepository->getExternalIds($tmdbId);
default:
return null;
}
});
if (null === $externalIds) {
return null;
}
return $externalIds->getImdbId() !== "" ? $externalIds->getImdbId() : "null";
}
public function getImages($tmdbId, $mediaType)
{
return $this->cache->get("tmdb.images.{$tmdbId}",
function (ItemInterface $item) use ($tmdbId, $mediaType) {
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return $this->movieRepository->getImages($tmdbId);
case MediaType::TvShow->value:
return $this->tvRepository->getImages($tmdbId);
default:
return null;
}
});
}
private function getReleaseDate($result, $mediaType): string
{
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return ($result->getReleaseDate() instanceof \DateTime)
? $result->getReleaseDate()->format('Y')
: $result->getReleaseDate();
case MediaType::TvShow->value:
return ($result->getFirstAirDate() instanceof \DateTime)
? $result->getFirstAirDate()->format('Y')
: $result->getFirstAirDate();
default:
return "";
}
}
private function getTitle($result, $mediaType): string
{
switch (MediaType::tryFrom($mediaType)->value) {
case MediaType::Movie->value:
return $result->getTitle();
case MediaType::TvShow->value:
return $result->getName();
default:
return "";
}
}
}

View File

@@ -1,269 +0,0 @@
<?php
namespace App\Tmdb;
use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Serializer\SerializerInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
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\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Repository\TvSeasonRepository;
use Tmdb\Token\Api\ApiToken;
use Tmdb\Token\Api\BearerToken;
class TmdbClient
{
const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
protected Client $client;
protected MovieRepository $movieRepository;
protected TvRepository $tvRepository;
protected TvSeasonRepository $tvSeasonRepository;
protected TvEpisodeRepository $tvEpisodeRepository;
protected SearchRepository $searchRepository;
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' => false,
'only_for_specified_models' => []
]
]
);
/**
* 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);
$this->movieRepository = new MovieRepository($this->client);
$this->tvRepository = new TvRepository($this->client);
$this->tvSeasonRepository = new TvSeasonRepository($this->client);
$this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
$this->searchRepository = new SearchRepository($this->client);
}
public function search(string $term): TmdbResult|Map
{
if (ImdbMatcher::isMatch($term)) {
$handlers = [
'movie' => 'movieDetails',
'tvshow' => 'tvshowDetails',
];
$data = $this->findByImdbId($term);
$handler = $handlers[$data['media_type']];
return $this->$handler($term);
}
return $this->parseListOfResults(
$this->searchRepository->getApi()->searchMulti($term),
"movies"
);
}
public function movieDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
return $this->parseResult(
$this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => 'external_ids']),
MediaType::Movie->value,
$imdbId
);
}
public function tvshowDetails(string $imdbId): ?TmdbResult
{
$tmdbId = $this->findByImdbId($imdbId)['id'];
$media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => 'external_ids']);
$media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
return $data['season_number'] !== 0 &&
strtolower($data['name']) !== 'specials';
})->map(function ($data) use ($media) {
return $this->tvSeasonDetails($media['id'], $data['season_number'])['episodes'];
})->rekey(function ($data) {
return $data[1]['season_number'];
})->toArray();
return $this->parseResult(
$media,
MediaType::TvShow->value,
$imdbId
);
}
public function tvSeasonDetails(string $tmdbId, int $season): array
{
$result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season);
$result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
$data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
$data['poster'] = $data['still_path'];
return $data;
})->rekey(fn ($data) => $data['episode_number'])->toArray();
return $result;
}
public function tvEpisodeDetails(string $tmdbId, int $season, int $episode): ?TmdbResult
{
$result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => 'external_ids']);
return $this->parseResult(
$result,
MediaType::TvEpisode->value,
$result['external_ids']['imdb_id']
);
}
public function relatedMedia(string $tmdbId, string $mediaType, int $resultCount = 6): Map
{
$repos = [
'movies' => $this->movieRepository,
'tvshows' => $this->tvRepository,
];
$results = $repos[$mediaType]->getApi()->getRecommendations($tmdbId);
return $this->parseListOfResults(
$results,
$mediaType,
$resultCount
);
}
public function popularMovies(int $resultCount = 6): Map
{
return $this->parseListOfResults(
$this->movieRepository->getApi()->getPopular(),
"movies",
$resultCount
);
}
public function popularTvShows(int $resultCount = 6): Map
{
return $this->parseListOfResults(
$this->tvRepository->getApi()->getPopular(),
"tvshows",
$resultCount
);
}
private function getExternalIds(string $tmdbId, $mediaType): ?array
{
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;
}
private function findByImdbId(string $imdbId): array
{
$finder = new Find($this->client);
$result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);
if (count($result['movie_results']) > 0) {
return $result['movie_results'][0];
} elseif (count($result['tv_results']) > 0) {
return $result['tv_results'][0];
} elseif (count($result['tv_episode_results']) > 0) {
return $result['tv_episode_results'][0];
}
throw new \Exception("No results found for $imdbId");
}
private function parseResult(array $data, string $mediaType, string $imdbId): TmdbResult
{
if (!array_key_exists('external_ids', $data)) {
$data['external_ids'] = ['imdb_id' => $imdbId];
}
return $this->serializer->denormalize($data, TmdbResult::class, context: ['media_type' => $mediaType]);
}
private function parseListOfResults(array $data, string $mediaType, ?int $resultCount = null): Map
{
$results = Map::from($data['results'])->filter(
fn ($result) => array_key_exists('id', $result)
)->map(function ($result) {
$result['external_ids'] = $this->getExternalIds($result['id'], MediaType::Movie->value);
return $result;
})->filter(function ($result) {
return array_key_exists('id', $result) &&
array_key_exists('imdb_id', $result['external_ids']) &&
$result['external_ids']['imdb_id'] !== null &&
$result['external_ids']['imdb_id'] !== "";
})->map(function ($result) use ($mediaType) {
return $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => $mediaType]);
});
if (null !== $resultCount) {
$results = $results->slice(0, $resultCount);
}
return $results;
}
}

View File

@@ -2,23 +2,16 @@
namespace App\Tmdb;
use Symfony\Component\Serializer\Attribute\SerializedPath;
class TmdbResult
{
public function __construct(
#[SerializedPath('[external_ids][imdb_id]')]
public ?string $imdbId = "",
#[SerializedPath('[id]')]
public ?int $tmdbId = null,
public ?string $tmdbId = "",
public ?string $title = "",
#[SerializedPath('[overview]')]
public ?string $description = "",
public ?string $poster = "",
public ?\DateTimeInterface $premiereDate = null,
public ?string $year = null,
public ?string $description = "",
public ?string $year = "",
public ?string $mediaType = "",
#[SerializedPath('[seasons]')]
public ?array $episodes = null,
public ?string $episodeAirDate = 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,7 +3,7 @@
namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Result\GetMovieOptionsResult;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
@@ -13,14 +13,14 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetMovieOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly TmdbClient $tmdb,
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->movieDetails($command->imdbId);
$media = $this->tmdb->mediaDetails($command->imdbId, 'movies');
return new GetMovieOptionsResult(
media: $media,
file: $this->mediaFiles->movieExists($media->title),

View File

@@ -2,10 +2,9 @@
namespace App\Torrentio\Action\Handler;
use App\Base\Enum\MediaType;
use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
use App\Torrentio\Client\Torrentio;
@@ -17,15 +16,15 @@ use OneToMany\RichBundle\Contract\ResultInterface;
class GetTvShowOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly TmdbClient $tmdb,
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->tvEpisodeDetails($command->tmdbId, $command->season, $command->episode);
$parentShow = $this->tmdb->tvshowDetails($command->imdbId);
$media = $this->tmdb->episodeDetails($command->tmdbId, $command->season, $command->episode);
$parentShow = $this->tmdb->mediaDetails($command->imdbId, 'tvshows');
$file = $this->mediaFiles->episodeExists($parentShow->title, $command->season, $command->episode);
return new GetTvShowOptionsResult(

View File

@@ -1,55 +0,0 @@
<?php
namespace App\Torrentio\Client;
use Carbon\Carbon;
use GuzzleHttp\Client as GuzzleClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class HttpClient
{
private GuzzleClient $client;
private string $baseUrl = 'https://torrentio.strem.fun/realdebrid=%s/';
public function __construct(
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
) {
$this->client = new GuzzleClient([
'base_uri' => sprintf($this->baseUrl, $this->realDebridKey),
]);
}
public function get(string $imdbId, array $cacheTags = []): array
{
$cacheKey = str_replace(":", ".", "torrentio.{$imdbId}");
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $cacheTags) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
if (count($cacheTags) > 0) {
$item->tag($cacheTags);
}
try {
$response = $this->client->get("stream/movie/$imdbId.json");
return json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
dd($exception);
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
return null;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
return [];
});
}
}

View File

@@ -2,19 +2,68 @@
namespace App\Torrentio\Client;
use App\Torrentio\Client\Rule\DownloadOptionFilter\Resolution;
use App\Torrentio\Client\Rule\RuleEngine;
use App\Torrentio\Result\ResultFactory;
use Carbon\Carbon;
use App\Torrentio\Exception\TorrentioRateLimitException;
use GuzzleHttp\Client;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
class Torrentio
{
private string $baseUrl = 'https://torrentio.strem.fun/realdebrid={realDebridKey}/stream/movie';
private string $searchUrl;
private Client $client;
public function __construct(
private readonly HttpClient $client,
) {}
#[Autowire(env: 'REAL_DEBRID_KEY')] private string $realDebridKey,
private TagAwareCacheInterface $cache,
private LoggerInterface $logger,
private Security $security,
) {
// $this->searchUrl = str_replace('{realDebridKey}', $this->realDebridKey, $this->baseUrl);
$user = $this->security->getUser();
$this->searchUrl = $user->getUserPreference('torrentio_url')->getPreferenceValue() . '/stream/movie';
// dd($this->searchUrl);
$this->client = new Client([
'base_uri' => $this->searchUrl,
]);
}
public function search(string $imdbCode, string $type, bool $parseResults = true): array
{
$cacheTags = ['torrentio', $type, $imdbCode];
$results = $this->client->get($imdbCode, $cacheTags);
$cacheKey = "torrentio.{$imdbCode}";
// $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbCode, $type) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
// $item->tag(['torrentio', $type, $imdbCode]);
try {
$response = $this->client->get("$this->searchUrl/$imdbCode.json");
$results = json_decode(
$response->getBody()->getContents(),
true
);
// dd($results);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
throw $exception;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
// return [];
// });
if (true === $parseResults) {
return $this->parse($results);
@@ -25,8 +74,26 @@ class Torrentio
public function fetchEpisodeResults(string $imdbId, int $season, int $episode, bool $parseResults = true): array
{
$cacheTags = ['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"];
$results = $this->client->get("$imdbId:$season:$episode", $cacheTags);
// $cacheKey = "torrentio.$imdbId.$season.$episode";
// $results = $this->cache->get($cacheKey, function (ItemInterface $item) use ($imdbId, $season, $episode) {
// $item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
// $item->tag(['torrentio', 'tvshows', 'torrentio.tvshows', $imdbId, "torrentio.$imdbId", "$imdbId.$season", "torrentio.$imdbId.$season", "$imdbId.$season.$episode", "torrentio.$imdbId.$season.$episode"]);
try {
$response = $this->client->get("$this->searchUrl/$imdbId:$season:$episode.json");
$results = json_decode(
$response->getBody()->getContents(),
true
);
} catch (\Throwable $exception) {
if ($exception->getCode() === 429) {
$this->logger->warning("> [TorrentioClient] Rate limit exceeded");
throw $exception;
}
}
$this->logger->error("> [TorrentioClient] Request error: " . $response->getStatusCode() . " - " . $response->getBody()->getContents());
// return [];
// });
if (null === $results) {
throw new TorrentioRateLimitException();

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Torrentio\Client;
use App\Values\TorrentioDebridProviderChoices;
class TorrentioUrl
{
const BASE_URL = "https://torrentio.strem.fun/";
public ?array $providers = null;
public ?array $language = null;
public ?array $qualityfilter = null;
public ?string $sorting = null;
public ?string $debridProvider = null;
public ?string $limit = null;
public ?string $sizefilter = null;
public ?string $debridToken = null;
public function __toString(): string
{
$result = "";
if (null !== $this->debridProvider && null !== $this->debridToken) {
$result .= "$this->debridProvider=$this->debridToken";
}
return static::BASE_URL . $result;
}
public static function fromString(string $url): self
{
$arrayData = ['providers', 'language', 'qualityfilter'];
$data = explode('|', str_replace('/', '', urldecode(urldecode(parse_url($url)['path']))));
$result = new self();
foreach ($data as $item) {$item = explode('=', $item);
if (count($item) !== 2) continue;
if (in_array($item[0], array_keys(TorrentioDebridProviderChoices::$providers))) {
$result->debridProvider = $item[0];
$result->debridToken = $item[1];
} elseif (in_array($item[0], $arrayData)) {
$result->{$item[0]} = explode(',', $item[1]);
} else {
$result->{$item[0]} = $item[1];
}
}
return $result;
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Torrentio\Framework\Form;
use Aimeos\Map;
use App\User\Database\ProviderList;
use App\Values\TorrentioDebridProviderChoices;
use App\Values\TorrentioExcludeQualityChoices;
use App\Values\TorrentioLanguageChoices;
use App\Values\TorrentioProviderChoices;
use App\Values\TorrentioSortChoices;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class TorrentioPreferencesForm extends AbstractType
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$this->addChoiceField($builder, 'providers', TorrentioProviderChoices::asSelectOptions());
$this->addChoiceField($builder, 'sorting', TorrentioSortChoices::asSelectOptions(), 1);
$this->addChoiceField($builder, 'language', TorrentioLanguageChoices::asSelectOptions());
$this->addChoiceField($builder, 'qualityfilter', TorrentioExcludeQualityChoices::asSelectOptions());
$this->addTextField($builder, 'limit');
$this->addTextField($builder, 'sizefilter');
$this->addChoiceField($builder, 'debridProvider', TorrentioDebridProviderChoices::asSelectOptions(), 1);
$this->addTextField($builder, 'debridToken');
}
private function addChoiceField(FormBuilderInterface $builder, string $fieldName, array $choices, ?int $maxItems = null): void
{
$question = [
'attr' => [
'class' => 'min-w-24 text-input mb-4',
],
'row_attr' => [
'class' => 'filter-label text-white'
],
'label_attr' => ['class' => 'block font-semibold mb-2'],
'choices' => $choices,
'required' => false,
];
if (null === $maxItems) {
$question['multiple'] = true;
$question['attr'] += [
'data-result-filter-target' => $fieldName,
'data-controller' => 'symfony--ux-autocomplete--autocomplete',
'data-symfony--ux-autocomplete--autocomplete-tom-select-options-value' => json_encode([
'highlight' => false,
'maxItems' => $maxItems,
]),
];
} else {
$question += [
'multiple' => false,
'expanded' => false,
];
}
$builder->add($fieldName, ChoiceType::class, $question);
}
public function addTextField(FormBuilderInterface $builder, string $fieldName, ?array $options = null): void
{
$optinos = $options ?? [
'required' => false,
'attr' => [
'method' => 'post',
'action' => $this->urlGenerator->generate('app.torrentio-preferences.save'),
'class' => 'min-w-24 text-input mb-4 block',
]
];
$builder->add(
$fieldName,
TextType::class,
$optinos,
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'id' => 'torrentio-preferences-form',
// 'action' => $this->urlGenerator->generate('app_user_media_preferences_submit'),
'attr' => [
'class' => 'filter-items w-full p-4 text-md dark:text-gray-50 rounded-lg',
]
]);
}
}

View File

@@ -49,7 +49,7 @@ final class TvEpisodeList
}
$this->reloadCount++;
// dd(new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage));
return new TvEpisodePaginator()->paginate($results, $this->pageNumber, $this->perPage);
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Twig\Components;
use Aimeos\Map;
use App\Monitor\Factory\UpcomingEpisodeDto;
use App\Monitor\Framework\Entity\Monitor;
use App\Tmdb\Tmdb;
use Carbon\CarbonImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Tmdb\Model\Tv\Episode;
#[AsTwigComponent]
final class UpcomingEpisodes extends AbstractController
{
// Get active monitors
// Search TMDB for upcoming episodes
public function __construct(
private readonly Tmdb $tmdb,
) {}
public function getUpcomingEpisodes(int $limit = 5): array
{
$upcomingEpisodes = new Map();
$monitors = $this->getMonitors();
foreach ($monitors as $monitor) {
$upcomingEpisodes->merge($this->getNextEpisodes($monitor));
}
return $upcomingEpisodes->slice(0, $limit)->toArray();
}
private function getMonitors()
{
$user = $this->getUser();
return $user->getMonitors()->filter(
fn (Monitor $monitor) => null === $monitor->getParent() && $monitor->isActive()
) ?? [];
}
private function getNextEpisodes(Monitor $monitor): Map
{
$today = CarbonImmutable::now();
$seriesInfo = $this->tmdb->tvDetails($monitor->getTmdbId());
switch ($monitor->getMonitorType()) {
case "tvseason":
$episodes = Map::from($seriesInfo->episodes[$monitor->getSeason()])
->filter(function (array $episode) use ($today) {
$airDate = CarbonImmutable::parse($episode['air_date']);
return $airDate->lte($today);
})
;
break;
case "tvshows":
$episodes = [];
foreach ($seriesInfo->episodes as $season => $episodeList) {
$episodes = array_merge($episodes, $episodeList);
}
$episodes = Map::from($episodes)
->filter(function (array $episode) use ($today) {
$airDate = CarbonImmutable::parse($episode['air_date']);
return $airDate->gte($today);
})
;
break;
}
return $episodes->map(function (array $episode) use ($monitor) {
return new UpcomingEpisodeDto(
$monitor->getTitle(),
$episode['air_date'],
$episode['name'],
$episode['episode_number'],
);
});
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\User\Action\Command;
use App\Torrentio\Client\TorrentioUrl;
use OneToMany\RichBundle\Contract\CommandInterface;
/** @implements CommandInterface<SaveUserMediaPreferencesCommand> */
class SaveUserTorrentioPreferencesCommand implements CommandInterface
{
public function __construct(
public TorrentioUrl $torrentioUrl,
) {}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\User\Action\Handler;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Result\SaveUserDownloadPreferencesResult;
use App\User\Action\Result\SaveUserMediaPreferencesResult;
use App\User\Framework\Entity\User;
use App\User\Framework\Entity\UserPreference;
use App\User\Framework\Repository\PreferencesRepository;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface as C;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface as R;
use Symfony\Bundle\SecurityBundle\Security;
/** @implements HandlerInterface<SaveUserMediaPreferencesCommand> */
class SaveUserTorrentioPreferencesHandler implements HandlerInterface
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly PreferencesRepository $preferenceRepository,
private readonly Security $token,
) {}
public function handle(C $command): R
{
/** @var User $user */
$user = $this->token->getUser();
if ($user->hasUserPreference('torrentio_url')) {
$user->updateUserPreference('torrentio_url', (string) $command->torrentioUrl);
$this->entityManager->flush();
} else {
$preference = $this->preferenceRepository->find('torrentio_url');
$user->addUserPreference(
(new UserPreference())
->setUser($user)
->setPreference($preference)
->setPreferenceValue((string) $command->torrentioUrl)
);
}
$this->entityManager->flush();
return new SaveUserDownloadPreferencesResult($user->getDownloadPreferences());
}
}

View File

@@ -5,20 +5,31 @@ namespace App\User\Database;
class ProviderList
{
public static $providers = [
'1337x',
'Comando',
'YTS',
'EZTV',
'ilCorSaRoNeRo',
'MagnetDL',
'MejorTorrent',
'RARBG',
'Rutor',
'Rutracker',
'1337x',
'ThePirateBay',
'Torrent9',
'KickassTorrents',
'TorrentGalaxy',
'MagnetDL',
'HorribleSubs',
'NyaaSi',
'TokyoTosho',
'AniDex',
'🇷🇺 Rutor',
'🇷🇺 Rutracker',
'🇵🇹 Comando',
'🇵🇹 BluDV',
'🇫🇷 Torrent9',
'🇮🇹 ilCorSaRoNeRo',
'🇪🇸 MejorTorrent',
'🇪🇸 Wolfmax4k',
'🇲🇽 Cinecalidad',
'🇵🇱 BestTorrents'
];
public static function getProviders()
{
return self::$providers;

View File

@@ -5,10 +5,14 @@ declare(strict_types=1);
namespace App\User\Framework\Controller\Web;
use App\Base\Service\Broadcaster;
use App\Torrentio\Client\TorrentioUrl;
use App\Torrentio\Framework\Form\TorrentioPreferencesForm;
use App\User\Action\Command\SaveUserMediaPreferencesCommand;
use App\User\Action\Command\SaveUserTorrentioPreferencesCommand;
use App\User\Action\Handler\SaveUserCalendarPreferencesHandler;
use App\User\Action\Handler\SaveUserDownloadPreferencesHandler;
use App\User\Action\Handler\SaveUserMediaPreferencesHandler;
use App\User\Action\Handler\SaveUserTorrentioPreferencesHandler;
use App\User\Action\Input\SaveUserCalendarPreferencesInput;
use App\User\Action\Input\SaveUserDownloadPreferencesInput;
use App\User\Action\Input\SaveUserMediaPreferencesInput;
@@ -38,6 +42,9 @@ class PreferencesController extends AbstractController
$calendarPreferences = $this->getUser()->getCalendarPreferences();
$formData = (array) UserPreferencesFactory::createFromUser($this->getUser());
$form = $this->createForm(UserMediaPreferencesForm::class, $formData);
$torrentioForm = $this->createForm(TorrentioPreferencesForm::class, TorrentioUrl::fromString(
$this->getUser()->getUserPreference('torrentio_url')->getPreferenceValue()
));
return $this->render(
'user/preferences.html.twig',
@@ -45,6 +52,7 @@ class PreferencesController extends AbstractController
'downloadPreferences' => $downloadPreferences,
'calendarPreferences' => $calendarPreferences,
'preferences_form' => $form,
'torrentio_form' => $torrentioForm,
]
);
}
@@ -131,4 +139,26 @@ class PreferencesController extends AbstractController
]
);
}
#[Route('/user/preferences/torrentio', 'app.torrentio-preferences.save', methods: ['POST'])]
public function saveTorrentioPreferences(Request $request, SaveUserTorrentioPreferencesHandler $handler)
{
$form = $this->createForm(TorrentioPreferencesForm::class, new TorrentioUrl());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$command = new SaveUserTorrentioPreferencesCommand(
$form->getData(),
);
$handler->handle($command);
$this->broadcaster->alert('Success', 'Your Torrentio preferences have been saved.');
}
return $this->render(
'user/preferences.html.twig',
[
'preferences_form' => $form,
]
);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\User\Framework\Entity;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use App\Monitor\Framework\Entity\Monitor;
use App\Torrentio\Client\TorrentioUrl;
use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -342,4 +343,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this->hasUserPreference('enable_ical_up_ep') &&
(bool) $this->getUserPreference('enable_ical_up_ep')->getPreferenceValue() === true;
}
public function getTorrentioUrl()
{
return $this->hasUserPreference('torrentio_url')
? TorrentioUrl::fromString($this->getUserPreference('torrentio_url')->getPreferenceValue())
: null;
}
}

View File

@@ -21,7 +21,7 @@ class UserPreference
#[ORM\JoinColumn(nullable: false)]
private ?Preference $preference = null;
#[ORM\Column(length: 255, nullable: true)]
#[ORM\Column(length: 1024, nullable: true)]
private ?string $preference_value = null;
public function getId(): ?int

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Values;
class TorrentioDebridProviderChoices
{
public static array $providers = [
'realdebrid' => 'RealDebrid',
'premiumize' => 'Premiumize',
'alldebrid' => 'AllDebrid',
'debridlink' => 'DebridLink',
'easydebrid' => 'EasyDebrid',
'offcloud' => 'Offcloud',
'torbox' => 'TorBox',
'putio' => 'Put.io',
];
public static function getProviders(): array
{
return self::$providers;
}
public static function asSelectOptions(): array
{
return array_flip(self::$providers);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Values;
class TorrentioExcludeQualityChoices
{
public static array $choices = [
'brremux' => 'BluRay REMUX',
'hdrall' => 'HDR/HDR10+/Dolby Vision',
'dolbyvision' => 'Dolby Vision',
'dolbyvisionwithhdr' => 'Dolby Vision + HDR',
'threed' => '3D',
'nonthreed' => 'Non 3D (DO NOT SELECT IF NOT SURE)',
'4k' => '4k',
'1080p' => '1080p',
'720p' => '720p',
'480p' => '480p',
'other' => 'Other (DVDRip/HDRip/BDRip...)',
'scr' => 'Screener',
'cam' => 'Cam',
'unknown' => 'Unknown'
];
public static function getChoices(): array
{
return self::$choices;
}
public static function asSelectOptions(): array
{
return array_flip(self::$choices);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Values;
class TorrentioLanguageChoices
{
public static array $languages = [
'japanese' => '🇯🇵 Japanese',
'russian' => '🇷🇺 Russian',
'italian' => '🇮🇹 Italian',
'portuguese' => '🇵🇹 Portuguese',
'spanish' => '🇪🇸 Spanish',
'latino' => '🇲🇽 Latino',
'korean' => '🇰🇷 Korean',
'chinese' => '🇨🇳 Chinese',
'taiwanese' => '🇹🇼 Taiwanese',
'french' => '🇫🇷 French',
'german' => '🇩🇪 German',
'dutch' => '🇳🇱 Dutch',
'hindi' => '🇮🇳 Hindi',
'telugu' => '🇮🇳 Telugu',
'tamil' => '🇮🇳 Tamil',
'polish' => '🇵🇱 Polish',
'lithuanian' => '🇱🇹 Lithuanian',
'latvian' => '🇱🇻 Latvian',
'estonian' => '🇪🇪 Estonian',
'czech' => '🇨🇿 Czech',
'slovakian' => '🇸🇰 Slovakian',
'slovenian' => '🇸🇮 Slovenian',
'hungarian' => '🇭🇺 Hungarian',
'romanian' => '🇷🇴 Romanian',
'bulgarian' => '🇧🇬 Bulgarian',
'serbian' => '🇷🇸 Serbian',
'croatian' => '🇭🇷 Croatian',
'ukrainian' => '🇺🇦 Ukrainian',
'greek' => '🇬🇷 Greek',
'danish' => '🇩🇰 Danish',
'finnish' => '🇫🇮 Finnish',
'swedish' => '🇸🇪 Swedish',
'norwegian' => '🇳🇴 Norwegian',
'turkish' => '🇹🇷 Turkish',
'arabic' => '🇸🇦 Arabic',
'persian' => '🇮🇷 Persian',
'hebrew' => '🇮🇱 Hebrew',
'vietnamese' => '🇻🇳 Vietnamese',
'indonesian' => '🇮🇩 Indonesian',
'malay' => '🇲🇾 Malay',
'thai' => '🇹🇭 Thai'
];
public static function getLanguages(): array
{
return self::$languages;
}
public static function asSelectOptions(): array
{
return array_flip(self::$languages);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Values;
class TorrentioProviderChoices
{
public static array $choices = [
'yts' => 'YTS',
'eztv' => 'EZTV',
'rarbg' => 'RARBG',
'1337x' => '1337x',
'thepiratebay' => 'ThePirateBay',
'kickasstorrents' => 'KickassTorrents',
'torrentgalaxy' => 'TorrentGalaxy',
'magnetdl' => 'MagnetDL',
'horriblesubs' => 'HorribleSubs',
'nyaasi' => 'NyaaSi',
'tokyotosho' => 'TokyoTosho',
'anidex' => 'AniDex',
'rutor' => '🇷🇺 Rutor',
'rutracker' => '🇷🇺 Rutracker',
'comando' => '🇵🇹 Comando',
'bludv' => '🇵🇹 BluDV',
'torrent9' => '🇫🇷 Torrent9',
'ilcorsaronero' => '🇮🇹 ilCorSaRoNeRo',
'mejortorrent' => '🇪🇸 MejorTorrent',
'wolfmax4k' => '🇪🇸 Wolfmax4k',
'cinecalidad' => '🇲🇽 Cinecalidad',
'besttorrents' => '🇵🇱 BestTorrents'
];
public static function getChoices(): array
{
return self::$choices;
}
public static function asSelectOptions(): array
{
return array_flip(self::$choices);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Values;
class TorrentioSortChoices
{
public static array $choices = [
'quality' => 'By quality then seeders',
'qualitysize' => 'By quality then size',
'seeders' => 'By seeders',
'size' => 'By size',
];
public static function getChoices(): array
{
return self::$choices;
}
public static function asSelectOptions(): array
{
return array_flip(self::$choices);
}
}

View File

@@ -1,4 +1,4 @@
<button class="submit-button flex flex-row gap-2 items-center">
<button class="submit-button {{ class|default('flex flex-row gap-2 items-center') }}">
{% if show_icon|default %}
<twig:ux:icon name="zondicons:checkmark" width=".8rem" class="text-green-500" />
{% endif %}

View File

@@ -57,4 +57,30 @@
</form>
</twig:Card>
</div>
<div class="p-4 flex flex-col md:flex-row gap-2">
<twig:Card title="Torrentio Preferences" class="w-full">
<p class="text-gray-50 mb-4">Configure your Torrentio client.</p>
{{ form_start(torrentio_form, {
action: path('app.torrentio-preferences.save')
}) }}
<div class="flex flex-col md:flex-row gap-2">
<div class="self-end mb-4">
{{ form_row(torrentio_form.providers) }}
{{ form_row(torrentio_form.sorting) }}
{{ form_row(torrentio_form.language) }}
{{ form_row(torrentio_form.qualityfilter) }}
{{ form_row(torrentio_form.limit) }}
{{ form_row(torrentio_form.sizefilter) }}
{{ form_row(torrentio_form.debridProvider) }}
{{ form_row(torrentio_form.debridToken) }}
{{ form_widget(torrentio_form._token) }}
<div class="w-[5rem]">
<twig:SubmitButton show_icon text="Save"/>
</div>
</div>
</div>
{{ form_end(preferences_form) }}
</twig:Card>
</div>
{% endblock %}

View File

@@ -9,7 +9,7 @@ use App\Monitor\Action\Handler\MonitorTvShowHandler;
use App\Monitor\Action\Result\MonitorTvShowResult;
use App\Monitor\Framework\Entity\Monitor;
use App\Monitor\Framework\Repository\MonitorRepository;
use App\Tmdb\TmdbClient;
use App\Tmdb\Tmdb;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
@@ -22,7 +22,7 @@ class MonitorTvShowHandlerTest extends TestCase
private MonitorTvEpisodeHandler $episodeHandler;
private MediaFiles $mediaFiles;
private LoggerInterface $logger;
private TmdbClient $tmdb;
private Tmdb $tmdb;
protected function setUp(): void
{
@@ -31,7 +31,7 @@ class MonitorTvShowHandlerTest extends TestCase
$this->episodeHandler = $this->createMock(MonitorTvEpisodeHandler::class);
$this->mediaFiles = $this->createMock(MediaFiles::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->tmdb = $this->createMock(TmdbClient::class);
$this->tmdb = $this->createMock(Tmdb::class);
$this->handler = new MonitorTvShowHandler(
$this->monitorRepository,