diff --git a/assets/controllers/result_filter_controller.js b/assets/controllers/result_filter_controller.js index baf3fb3..bd552a3 100644 --- a/assets/controllers/result_filter_controller.js +++ b/assets/controllers/result_filter_controller.js @@ -158,7 +158,7 @@ export default class extends Controller { } downloadSeason() { - fetch(`/api/download/${this.imdbIdValue}/${this.activeFilter['season']}`, { + fetch(`/api/download/season/${this.imdbIdValue}/${this.activeFilter['season']}`, { headers: { 'Content-Type': 'application/json' } diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index b00daeb..13e9d14 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -37,6 +37,7 @@ framework: # Route your messages to the transports # 'App\Message\YourMessage': async 'App\Download\Action\Command\DownloadMediaCommand': async + 'App\Download\Action\Command\DownloadSeasonCommand': async 'App\Monitor\Action\Command\MonitorTvEpisodeCommand': async 'App\Monitor\Action\Command\MonitorTvSeasonCommand': async 'App\Monitor\Action\Command\MonitorTvShowCommand': async diff --git a/docs/examples/.env b/docs/examples/.env index c474e18..b153d04 100644 --- a/docs/examples/.env +++ b/docs/examples/.env @@ -3,7 +3,7 @@ # or pass your certificates into the 'app' container. # Please omit any trailing slashes. The APP_URL is # used to generate the Mercure URL behind the scenes. -APP_URL="https://torsearch.idocode.io" +APP_URL="https://dev.caldwell.digital" APP_SECRET="70169beadfbc8101c393cbfbba27a313" APP_ENV=prod diff --git a/docs/examples/compose.yml b/docs/examples/compose.yml index 167eba1..f10a967 100644 --- a/docs/examples/compose.yml +++ b/docs/examples/compose.yml @@ -1,4 +1,16 @@ services: + caddy: + image: caddy:2.9.1 + restart: unless-stopped + cap_add: + - NET_ADMIN + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - $PWD/../../bash/caddy:/etc/caddy + - $PWD/../../bash/certs:/etc/ssl # The "entrypoint" into the application. This reverse proxy # proxies traffic back to their respective services. If not # running behind a reverse proxy inject your SSL certificates @@ -12,8 +24,8 @@ services: env_file: - .env volumes: - - /mnt/media/downloads/movies:/var/download/movies - - /mnt/media/downloads/tvshows:/var/download/tvshows + - ./downloads/movies:/var/download/movies + - ./downloads/tvshows:/var/download/tvshows environment: TZ: America/Chicago MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' @@ -32,8 +44,8 @@ services: worker: image: code.caldwell.digital/home/torsearch-worker:latest volumes: - - /mnt/media/downloads/movies:/var/download/movies - - /mnt/media/downloads/tvshows:/var/download/tvshows + - ./downloads/movies:/var/download/movies + - ./downloads/tvshows:/var/download/tvshows environment: TZ: America/Chicago command: -vvv @@ -52,8 +64,8 @@ services: scheduler: image: code.caldwell.digital/home/torsearch-scheduler:latest volumes: - - /mnt/media/downloads/movies:/var/download/movies - - /mnt/media/downloads/tvshows:/var/download/tvshows + - ./downloads/movies:/var/download/movies + - ./downloads/tvshows:/var/download/tvshows env_file: - .env environment: diff --git a/migrations/Version20250708033046.php b/migrations/Version20250708033046.php new file mode 100644 index 0000000..d327b2f --- /dev/null +++ b/migrations/Version20250708033046.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + DROP TABLE sessions + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE download CHANGE created_at created_at DATETIME NOT NULL, CHANGE updated_at updated_at DATETIME NOT NULL + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE monitor ADD only_future TINYINT(1) NOT NULL DEFAULT 1 + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + CREATE TABLE sessions (sess_id VARBINARY(128) NOT NULL, sess_data LONGBLOB NOT NULL, sess_lifetime INT UNSIGNED NOT NULL, sess_time INT UNSIGNED NOT NULL, INDEX sess_lifetime_idx (sess_lifetime), PRIMARY KEY(sess_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_bin` ENGINE = InnoDB COMMENT = '' + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE monitor DROP only_future + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE download CHANGE created_at created_at DATETIME DEFAULT NULL, CHANGE updated_at updated_at DATETIME DEFAULT NULL + SQL); + } +} diff --git a/src/Base/Framework/Command/SeedDatabaseCommand.php b/src/Base/Framework/Command/SeedDatabaseCommand.php index 3aa84f6..03aa9d5 100644 --- a/src/Base/Framework/Command/SeedDatabaseCommand.php +++ b/src/Base/Framework/Command/SeedDatabaseCommand.php @@ -7,15 +7,11 @@ 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 Doctrine\ORM\EntityManagerInterface; -use Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; #[AsCommand( name: 'db:seed', diff --git a/src/Download/Action/Command/DownloadSeasonCommand.php b/src/Download/Action/Command/DownloadSeasonCommand.php new file mode 100644 index 0000000..81b9679 --- /dev/null +++ b/src/Download/Action/Command/DownloadSeasonCommand.php @@ -0,0 +1,18 @@ + + */ +class DownloadSeasonCommand implements CommandInterface +{ + public function __construct( + public int $userId, + public int $season, + public string $imdbId, + public string $mediaType = 'tvshows', + ) {} +} \ No newline at end of file diff --git a/src/Download/Action/Handler/DownloadSeasonHandler.php b/src/Download/Action/Handler/DownloadSeasonHandler.php new file mode 100644 index 0000000..db2ef54 --- /dev/null +++ b/src/Download/Action/Handler/DownloadSeasonHandler.php @@ -0,0 +1,110 @@ + */ +readonly class DownloadSeasonHandler implements HandlerInterface +{ + public function __construct( + private MediaFiles $mediaFiles, + private LoggerInterface $logger, + private Tmdb $tmdb, + private MessageBusInterface $bus, + private DownloadOptionEvaluator $downloadOptionEvaluator, + private GetTvShowOptionsHandler $getTvShowOptionsHandler, + private UserRepository $userRepository, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + $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]); + $this->logger->info('> [DownloadTvSeasonHandler] ...Found ' . count($episodesInSeason) . ' episodes in season ' . $command->season); + + $downloadCommands = []; + foreach ($episodesInSeason as $episode) { + $this->logger->info('> [DownloadTvSeasonHandler] ...Evaluating episode ' . $episode['episode_number']); + + $results = $this->getTvShowOptionsHandler->handle( + new GetTvShowOptionsCommand( + $series->tmdbId, + $command->imdbId, + $command->season, + $episode['episode_number'] + ) + ); + + $this->logger->info('> [DownloadTvSeasonHandler] ...Found ' . count($results->results) . ' total download options, beginning evaluation'); + + $userPreferences = UserPreferencesFactory::createFromUser( + $this->userRepository->findOneBy(['id' => $command->userId]) + ); + + $result = $this->downloadOptionEvaluator->evaluateOptions($results->results, $userPreferences); + + if (null !== $result) { + $this->logger->info('> [DownloadTvSeasonHandler] ......Found 1 matching result: dispatching DownloadMediaCommand for "' . $result->title . '"'); + $downloadCommand = new DownloadMediaCommand( + $result->url, + $series->title, + $result->filename, + 'tvshows', + $command->imdbId, + $command->userId, + ); + $this->bus->dispatch($downloadCommand); + $downloadCommands[] = $downloadCommand; + } else { + $this->logger->info('> [DownloadTvSeasonHandler] ......Found 0 matching results'); + } + } + + return new DownloadSeasonResult( + status: 200, + message: 'Success', + data: ['downloads' => $downloadCommands], + ); + } + + private function getDownloadedEpisodes(string $title) + { + // Check current episodes + $downloadedEpisodes = $this->mediaFiles + ->getEpisodes($title) + ->map(fn($episode) => (object) (new PTN())->parse($episode)) + ->filter(fn ($episode) => + property_exists($episode, 'episode') + && property_exists($episode, 'season') + && null !== $episode->episode + && null !== $episode->season + ) + ->rekey(fn($episode) => $episode->episode); + $this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); + } +} diff --git a/src/Download/Action/Input/DownloadSeasonInput.php b/src/Download/Action/Input/DownloadSeasonInput.php new file mode 100644 index 0000000..a221037 --- /dev/null +++ b/src/Download/Action/Input/DownloadSeasonInput.php @@ -0,0 +1,37 @@ + */ +class DownloadSeasonInput implements InputInterface +{ + public function __construct( + #[SourceRoute('imdbId')] + public string $imdbId, + + #[SourceRoute('season')] + public int $season, + + #[SourceRequest('mediaType')] + public string $mediaType = 'tvshows', + + public ?int $userId = null, + ) {} + + public function toCommand(): CommandInterface + { + return new DownloadSeasonCommand( + $this->userId, + $this->season, + $this->imdbId, + $this->mediaType, + ); + } +} \ No newline at end of file diff --git a/src/Download/Action/Result/DownloadSeasonResult.php b/src/Download/Action/Result/DownloadSeasonResult.php new file mode 100644 index 0000000..0736ad1 --- /dev/null +++ b/src/Download/Action/Result/DownloadSeasonResult.php @@ -0,0 +1,15 @@ + */ +class DownloadSeasonResult implements ResultInterface +{ + public function __construct( + public int $status, + public string $message, + public array $data, + ) {} +} diff --git a/src/Download/DownloadOptionEvaluator.php b/src/Download/DownloadOptionEvaluator.php new file mode 100644 index 0000000..bc41e62 --- /dev/null +++ b/src/Download/DownloadOptionEvaluator.php @@ -0,0 +1,94 @@ +language, $result->languages)) { + continue; + } + + if ($result->resolution === $userPreferences->resolution + && $result->codec === $userPreferences->codec + ) { + $bestMatches[] = $result; + } + + if ($userPreferences->resolution === '2160p' + && $userPreferences->codec === $result->codec + && $result->resolution === '1080p' + ) { + $matches[] = $result; + } + + if ($userPreferences->codec === 'h264' + && $userPreferences->resolution === $result->resolution + && $result->codec === 'h265' + ) { + $matches[] = $result; + } + + if (($userPreferences->codec === null ) + && ($userPreferences->resolution === null )) { + $matches[] = $result; + } + } + + $sizeMatches = []; + + foreach ($bestMatches as $result) { + if (str_contains($result->size, 'GB')) { + $size = (int) trim(str_replace('GB', '', $result->size)) * 1024; + } else { + $size = (int) trim(str_replace('MB', '', $result->size)); + } + + if ($size > $sizeLow && $size < $sizeHigh) { + $sizeMatches[] = $result; + } + } + + if (!empty($sizeMatches)) { + return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last(); + } + + foreach ($matches as $result) { + $size = 0; + if (str_contains($result->size, 'GB')) { + $size = (int) trim(str_replace('GB', '', $result->size)) * 1024; + } else { + $size = (int) trim(str_replace('MB', '', $result->size)); + } + + if ($size > $sizeLow && $size < $sizeHigh) { + $sizeMatches[] = $result; + } + } + + if (!empty($sizeMatches)) { + return Map::from($sizeMatches)->usort(fn($a, $b) => $a->seeders <=> $b->seeders)->last(); + } + + return null; + } +} diff --git a/src/Download/Framework/Controller/ApiController.php b/src/Download/Framework/Controller/ApiController.php index f4c8659..b28bfb9 100644 --- a/src/Download/Framework/Controller/ApiController.php +++ b/src/Download/Framework/Controller/ApiController.php @@ -4,13 +4,16 @@ namespace App\Download\Framework\Controller; use App\Base\Util\Broadcaster; use App\Download\Action\Handler\DeleteDownloadHandler; +use App\Download\Action\Handler\DownloadSeasonHandler; use App\Download\Action\Handler\PauseDownloadHandler; use App\Download\Action\Handler\ResumeDownloadHandler; use App\Download\Action\Input\DeleteDownloadInput; use App\Download\Action\Input\DownloadMediaInput; +use App\Download\Action\Input\DownloadSeasonInput; use App\Download\Action\Input\PauseDownloadInput; use App\Download\Action\Input\ResumeDownloadInput; use App\Download\Framework\Repository\DownloadRepository; +use App\User\Dto\UserPreferencesFactory; use Nihilarr\PTN; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; @@ -105,4 +108,18 @@ class ApiController extends AbstractController return $this->json(['status' => 200, 'message' => 'Download Resumed']); } + + #[Route('/api/download/season/{imdbId}/{season}', name: 'api_download_season', methods: ['GET'])] + public function downloadSeason( + DownloadSeasonInput $input, + ): Response { + $input->userId = $this->getUser()->getId(); + $this->bus->dispatch($input->toCommand()); + $this->broadcaster->alert( + title: 'Success', + message: "Your download for season $input->season has been added to the queue.", + ); + + return $this->json(['status' => 200, 'message' => 'Download Resumed']); + } } diff --git a/src/Monitor/Framework/Entity/Monitor.php b/src/Monitor/Framework/Entity/Monitor.php index f073336..5a54f0d 100644 --- a/src/Monitor/Framework/Entity/Monitor.php +++ b/src/Monitor/Framework/Entity/Monitor.php @@ -50,6 +50,9 @@ class Monitor #[ORM\Column(nullable: true)] private ?int $searchCount = null; + #[ORM\Column] + private bool $onlyFuture = true; + #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)] private ?\DateTimeInterface $lastSearch = null; @@ -147,6 +150,11 @@ class Monitor return $this; } + public function isOnlyFuture(): bool + { + return $this->onlyFuture; + } + public function getLastSearch(): ?\DateTimeInterface { return Carbon::parse($this->lastSearch); diff --git a/src/User/Dto/UserPreferences.php b/src/User/Dto/UserPreferences.php index 3c9be5a..3b6bf77 100644 --- a/src/User/Dto/UserPreferences.php +++ b/src/User/Dto/UserPreferences.php @@ -6,10 +6,10 @@ class UserPreferences { public function __construct( - public readonly string $resolution, - public readonly string $codec, - public readonly string $language, - public readonly string $provider, - public readonly string $quality, + public readonly ?string $resolution, + public readonly ?string $codec, + public readonly ?string $language, + public readonly ?string $provider, + public readonly ?string $quality, ) {} } diff --git a/src/User/Dto/UserPreferencesFactory.php b/src/User/Dto/UserPreferencesFactory.php index 4c7d1a4..ef15ea0 100644 --- a/src/User/Dto/UserPreferencesFactory.php +++ b/src/User/Dto/UserPreferencesFactory.php @@ -2,18 +2,46 @@ namespace App\User\Dto; +use App\User\Framework\Entity\PreferenceOption; use App\User\Framework\Entity\User; +use Symfony\Component\Security\Core\User\UserInterface; class UserPreferencesFactory { - public static function createFromUser(User $user): UserPreferences + /** @param User $user */ + public static function createFromUser(UserInterface $user): UserPreferences { return new UserPreferences( - resolution: $user->getUserPreference('resolution')->getPreferenceValue(), - codec: $user->getUserPreference('codec')->getPreferenceValue(), - language: $user->getUserPreference('language')->getPreferenceValue(), - provider: $user->getUserPreference('provider')->getPreferenceValue(), - quality: $user->getUserPreference('quality')->getPreferenceValue(), + resolution: self::getNestedValue($user, 'resolution'), + codec: self::getNestedValue($user, 'codec'), + language: self::getValue($user, 'language'), + provider: self::getValue($user, 'provider'), + quality: self::getValue($user, 'quality'), ); } + + /** @param User $user */ + private static function getValue(UserInterface $user, string $preferenceId) + { + $value = $user->getUserPreference($preferenceId)->getPreferenceValue(); + if ($value === "") { + return null; + } + return $value; + } + + /** @param User $user */ + private static function getNestedValue(UserInterface $user, string $preferenceId): ?string + { + $preference = $user->getUserPreference($preferenceId); + if (null === $preference) { + return null; + } + return $preference->getPreference() + ->getPreferenceOptions() + ->filter(fn (PreferenceOption $option) => (string) $option->getId() === $preference->getPreferenceValue()) + ->first() + ->getValue() + ; + } } diff --git a/src/User/Framework/Entity/User.php b/src/User/Framework/Entity/User.php index ef3f1f1..c662cd1 100644 --- a/src/User/Framework/Entity/User.php +++ b/src/User/Framework/Entity/User.php @@ -156,7 +156,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function getUserPreference(string $preferenceName): ?UserPreference { foreach ($this->userPreferences as $userPreference) { - if ($userPreference->getPreference()->getName() === $preferenceName) { + if ($userPreference->getPreference()->getName() === $preferenceName + || $userPreference->getPreference()->getId() === $preferenceName + ) { return $userPreference; } }