diff --git a/.gitignore b/.gitignore index e0f4ccf..a079886 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ /public/assets/ /assets/vendor/ ###< symfony/asset-mapper ### + +###> phpstan/phpstan ### +phpstan.neon +###< phpstan/phpstan ### diff --git a/composer.json b/composer.json index 3cae811..f899aa6 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "symfony/console": "7.2.*", "symfony/doctrine-messenger": "7.2.*", "symfony/dotenv": "7.2.*", + "symfony/finder": "7.2.*", "symfony/flex": "^2", "symfony/form": "7.2.*", "symfony/framework-bundle": "7.2.*", @@ -95,6 +96,7 @@ } }, "require-dev": { + "phpstan/phpstan": "^2.1", "symfony/maker-bundle": "^1.62", "symfony/stopwatch": "7.2.*", "symfony/web-profiler-bundle": "7.2.*" diff --git a/composer.lock b/composer.lock index 487ef6a..9a8479d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "97689e103d8e0ba79aba71891384895d", + "content-hash": "1acedc6a795947368d0673ec79564bec", "packages": [ { "name": "1tomany/rich-bundle", @@ -8488,6 +8488,64 @@ }, "time": "2024-12-30T11:07:19+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.14", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8f2e03099cac24ff3b379864d171c5acbfc6b9a2", + "reference": "8f2e03099cac24ff3b379864d171c5acbfc6b9a2", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-05-02T15:32:28+00:00" + }, { "name": "symfony/maker-bundle", "version": "v1.63.0", diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index fec8db5..a443e9d 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -25,6 +25,7 @@ framework: # Route your messages to the transports # 'App\Message\YourMessage': async 'App\Download\Action\Command\DownloadMediaCommand': async + 'App\Download\Action\Command\MonitorTvEpisodeCommand': async # when@test: # framework: diff --git a/config/services.yaml b/config/services.yaml index 37bb078..7748626 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -4,6 +4,10 @@ # Put parameters here that don't need to change on each machine where the app is deployed # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: + media.default_movies_dir: movies + media.default_tvshows_dir: tvshows + media.movies_path: '/var/download/%env(default:media.default_movies_dir:MOVIES_PATH)%' + media.tvshows_path: '/var/download/%env(default:media.default_tvshows_dir:TVSHOWS_PATH)%' services: # default configuration for services in *this* file diff --git a/migrations/Version20250505211458.php b/migrations/Version20250505211458.php new file mode 100644 index 0000000..3c2e116 --- /dev/null +++ b/migrations/Version20250505211458.php @@ -0,0 +1,53 @@ +addSql(<<<'SQL' + CREATE TABLE monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) DEFAULT NULL, imdb_id VARCHAR(255) NOT NULL, tmdb_id VARCHAR(255) NOT NULL, status VARCHAR(255) NOT NULL, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', monitor_type VARCHAR(255) NOT NULL, season INT DEFAULT NULL, episode INT DEFAULT NULL, INDEX IDX_E1159985A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE monitor ADD CONSTRAINT FK_E1159985A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE movie_monitor + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + CREATE TABLE movie_monitor (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, title VARCHAR(255) CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_unicode_ci`, imdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, tmdb_id VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, status VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, search_count INT DEFAULT NULL, last_search DATETIME DEFAULT NULL, created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', downloaded_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = '' + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE monitor DROP FOREIGN KEY FK_E1159985A76ED395 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE monitor + SQL); + } +} diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..e0de575 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,8 @@ +parameters: + level: 6 + paths: + - bin/ + - config/ + - public/ + - src/ + - tests/ diff --git a/src/Controller/DownloadController.php b/src/Controller/DownloadController.php index 2730534..72b7897 100644 --- a/src/Controller/DownloadController.php +++ b/src/Controller/DownloadController.php @@ -3,11 +3,18 @@ namespace App\Controller; use App\Download\Action\Command\MonitorMovieCommand; -use App\Download\Action\Handler\AddMovieMonitorHandler; +use App\Download\Action\Command\MonitorTvSeasonCommand; +use App\Download\Action\Command\MonitorTvShowCommand; use App\Download\Action\Handler\MonitorMovieHandler; -use App\Download\Action\Input\AddMovieMonitorInput; +use App\Download\Action\Handler\MonitorTvSeasonHandler; +use App\Download\Action\Handler\MonitorTvShowHandler; use App\Download\Action\Input\DownloadMediaInput; +use App\Download\Framework\Entity\Monitor; use App\Download\Framework\Repository\DownloadRepository; +use App\Download\Framework\Repository\MonitorRepository; +use App\Download\Service\MediaFiles; +use DateTimeImmutable; +use Nihilarr\PTN; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; @@ -18,13 +25,28 @@ class DownloadController extends AbstractController public function __construct( private DownloadRepository $downloadRepository, private MessageBusInterface $bus, + private readonly MonitorRepository $monitorRepository, ) {} #[Route('/test', name: 'app_test')] public function test( - MonitorMovieHandler $handler, + MonitorTvShowHandler $handler, ) { - $command = new MonitorMovieCommand(41); + $monitor = (new Monitor()) + ->setUser($this->getUser()) + ->setTmdbId('95396') + ->setImdbId('tt11280740') + ->setTitle('Severance') + ->setMonitorType('tvshow') + ->setSeason(1) + ->setEpisode(null) + ->setCreatedAt(new DateTimeImmutable()) + ->setSearchCount(0) + ->setStatus('New'); + $this->monitorRepository->getEntityManager()->persist($monitor); + $this->monitorRepository->getEntityManager()->flush(); + + $command = new MonitorTvShowCommand($monitor->getId()); $handler->handle($command); return $this->json([ 'status' => 200, @@ -53,16 +75,4 @@ class DownloadController extends AbstractController return $this->json(['status' => 200, 'message' => 'Added to Queue']); } - - #[Route('/monitor/movies/{tmdbId}/{imdbId}/{title}', name: 'app_add_movie_monitor', methods: ['GET', 'POST'])] - public function addMonitor( - AddMovieMonitorInput $input, - AddMovieMonitorHandler $handler, - ) { - $handler->handle($input->toCommand()); - return $this->json([ - 'status' => 200, - 'message' => $input - ]); - } } diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index dddd0b4..5fa3475 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -4,9 +4,9 @@ namespace App\Controller; use App\Download\Action\Command\MonitorMovieCommand; use App\Download\Action\Handler\MonitorMovieHandler; -use App\Download\Framework\Entity\MovieMonitor; +use App\Download\Framework\Entity\Monitor; use App\Download\Framework\Repository\DownloadRepository; -use App\Download\Framework\Repository\MovieMonitorRepository; +use App\Download\Framework\Repository\MonitorRepository; use App\Tmdb\Tmdb; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Download/Action/Command/AddMovieMonitorCommand.php b/src/Download/Action/Command/AddMonitorCommand.php similarity index 58% rename from src/Download/Action/Command/AddMovieMonitorCommand.php rename to src/Download/Action/Command/AddMonitorCommand.php index 55837eb..71ffdf4 100644 --- a/src/Download/Action/Command/AddMovieMonitorCommand.php +++ b/src/Download/Action/Command/AddMonitorCommand.php @@ -2,15 +2,18 @@ namespace App\Download\Action\Command; -use App\Download\Framework\Entity\MovieMonitor; +use App\Download\Framework\Entity\Monitor; use OneToMany\RichBundle\Contract\CommandInterface; -class AddMovieMonitorCommand implements CommandInterface +class AddMonitorCommand implements CommandInterface { public function __construct( public string $userEmail, public string $title, public string $imdbId, public string $tmdbId, + public string $monitorType, + public ?int $season, + public ?int $episode, ) {} } diff --git a/src/Download/Action/Command/MonitorTvEpisodeCommand.php b/src/Download/Action/Command/MonitorTvEpisodeCommand.php new file mode 100644 index 0000000..abebf96 --- /dev/null +++ b/src/Download/Action/Command/MonitorTvEpisodeCommand.php @@ -0,0 +1,12 @@ + */ +readonly class AddMonitorHandler implements HandlerInterface +{ + public function __construct( + private MonitorRepository $movieMonitorRepository, + private UserRepository $userRepository, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + $user = $this->userRepository->findOneBy(['email' => $command->userEmail]); + $monitor = (new Monitor()) + ->setUser($user) + ->setTmdbId($command->tmdbId) + ->setImdbId($command->imdbId) + ->setTitle($command->title) + ->setMonitorType($command->monitorType) + ->setSeason($command->season) + ->setEpisode($command->episode) + ->setCreatedAt(new DateTimeImmutable()) + ->setSearchCount(0) + ->setStatus('New'); + + $this->movieMonitorRepository->getEntityManager()->persist($monitor); + $this->movieMonitorRepository->getEntityManager()->flush(); + + return new AddMonitorResult( + status: 'OK', + result: [ + 'monitor' => $monitor, + ] + ); + } +} diff --git a/src/Download/Action/Handler/AddMovieMonitorHandler.php b/src/Download/Action/Handler/AddMovieMonitorHandler.php deleted file mode 100644 index 6f124ec..0000000 --- a/src/Download/Action/Handler/AddMovieMonitorHandler.php +++ /dev/null @@ -1,45 +0,0 @@ - */ -readonly class AddMovieMonitorHandler implements HandlerInterface -{ - public function __construct( - private MovieMonitorRepository $movieMonitorRepository, - private UserRepository $userRepository, - ) {} - - public function handle(CommandInterface $command): ResultInterface - { - $user = $this->userRepository->findOneBy(['email' => $command->userEmail]); - $monitor = new MovieMonitor(); - $monitor->setTmdbId($command->tmdbId); - $monitor->setImdbId($command->imdbId); - $monitor->setTitle($command->title); - $monitor->setUser($user); - $monitor->setCreatedAt(new DateTimeImmutable()); - $monitor->setSearchCount(0); - $monitor->setStatus('New'); - - $this->movieMonitorRepository->getEntityManager()->persist($monitor); - $this->movieMonitorRepository->getEntityManager()->flush(); - - return new MonitorMovieResult( - status: 'OK', - result: [ - 'monitor' => $monitor, - ] - ); - } -} diff --git a/src/Download/Action/Handler/MonitorMovieHandler.php b/src/Download/Action/Handler/MonitorMovieHandler.php index ed57369..211c132 100644 --- a/src/Download/Action/Handler/MonitorMovieHandler.php +++ b/src/Download/Action/Handler/MonitorMovieHandler.php @@ -5,7 +5,7 @@ namespace App\Download\Action\Handler; use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\MonitorMovieCommand; use App\Download\Action\Result\MonitorMovieResult; -use App\Download\Framework\Repository\MovieMonitorRepository; +use App\Download\Framework\Repository\MonitorRepository; use App\Download\Service\MonitorOptionEvaluator; use App\Torrentio\Action\Command\GetMovieOptionsCommand; use App\Torrentio\Action\Handler\GetMovieOptionsHandler; @@ -21,7 +21,7 @@ use Symfony\Component\Messenger\MessageBusInterface; readonly class MonitorMovieHandler implements HandlerInterface { public function __construct( - private MovieMonitorRepository $movieMonitorRepository, + private MonitorRepository $movieMonitorRepository, private GetMovieOptionsHandler $getMovieOptionsHandler, private MonitorOptionEvaluator $monitorOptionEvaluator, private EntityManagerInterface $entityManager, @@ -36,7 +36,6 @@ readonly class MonitorMovieHandler implements HandlerInterface $monitor->setStatus('In Progress'); $this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options'); - $results = $this->getMovieOptionsHandler->handle( new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId()) ); diff --git a/src/Download/Action/Handler/MonitorTvEpisodeHandler.php b/src/Download/Action/Handler/MonitorTvEpisodeHandler.php new file mode 100644 index 0000000..f38827b --- /dev/null +++ b/src/Download/Action/Handler/MonitorTvEpisodeHandler.php @@ -0,0 +1,76 @@ + */ +readonly class MonitorTvEpisodeHandler implements HandlerInterface +{ + public function __construct( + private MonitorRepository $movieMonitorRepository, + private GetMovieOptionsHandler $getMovieOptionsHandler, + private MonitorOptionEvaluator $monitorOptionEvaluator, + private EntityManagerInterface $entityManager, + private MessageBusInterface $bus, + private LoggerInterface $logger, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + $this->logger->info('> [MonitorTvEpisodeHandler] Executing MonitorTvEpisodeHandler'); + $monitor = $this->movieMonitorRepository->find($command->movieMonitorId); + $monitor->setStatus('In Progress'); + + $this->logger->info('> [MonitorTvEpisodeHandler] Searching for "' . $monitor->getTitle() . '" download options'); + $results = $this->getMovieOptionsHandler->handle( + new GetTvShowOptionsCommand( + $monitor->getTmdbId(), + $monitor->getImdbId(), + $monitor->getSeason(), + $monitor->getEpisode()) + ); + + $this->logger->info('> [MonitorTvEpisodeHandler] Found ' . count($results->results) . ' download options'); + + $result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results); + + if (null !== $result) { + $this->logger->info('> [MonitorTvEpisodeHandler] 1 result found: dispatching DownloadMediaCommand for "' . $result->title . '"'); + $this->bus->dispatch(new DownloadMediaCommand( + $result->url, + $monitor->getTitle(), + $result->filename, + 'movies', + $monitor->getImdbId(), + )); + $monitor->setStatus('Complete'); + $monitor->setDownloadedAt(new DateTimeImmutable()); + } + + $monitor->setLastSearch(new DateTimeImmutable()); + $monitor->setSearchCount($monitor->getSearchCount() + 1); + $this->entityManager->flush(); + + return new MonitorMovieResult( + status: 'OK', + result: [ + 'monitor' => $monitor, + ] + ); + } +} diff --git a/src/Download/Action/Handler/MonitorTvSeasonHandler.php b/src/Download/Action/Handler/MonitorTvSeasonHandler.php new file mode 100644 index 0000000..02b09e1 --- /dev/null +++ b/src/Download/Action/Handler/MonitorTvSeasonHandler.php @@ -0,0 +1,95 @@ + */ +readonly class MonitorTvSeasonHandler implements HandlerInterface +{ + public function __construct( + private MonitorRepository $monitorRepository, + private GetMovieOptionsHandler $getMovieOptionsHandler, + private MonitorOptionEvaluator $monitorOptionEvaluator, + private EntityManagerInterface $entityManager, + private MediaFiles $mediaFiles, + private MessageBusInterface $bus, + private LoggerInterface $logger, + private Tmdb $tmdb, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + $this->logger->info('> [MonitorTvSeasonHandler] Executing MonitorTvSeasonHandler'); + $monitor = $this->monitorRepository->find($command->monitorId); + + // Check current episodes + $downloadedEpisodes = $this->mediaFiles + ->getEpisodes($monitor->getTitle()) + ->map(fn($episode) => (object) (new PTN())->parse($episode)) + ->rekey(fn($episode) => $episode->episode); + $this->logger->info('> [MonitorTvSeasonHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); + + // Compare against list from TMDB + $episodesInSeason = Map::from( + $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()); + + // Dispatch Episode commands for each missing Episode + foreach ($episodesInSeason as $episode) { + if (!array_key_exists($episode['episode_number'], $downloadedEpisodes->toArray())) { + $monitor = (new Monitor()) + ->setUser($monitor->getUser()) + ->setTmdbId($monitor->getTmdbId()) + ->setImdbId($monitor->getImdbId()) + ->setTitle($monitor->getTitle()) + ->setMonitorType('tvseason') + ->setSeason($monitor->getSeason()) + ->setEpisode($episode['episode_number']) + ->setCreatedAt(new DateTimeImmutable()) + ->setSearchCount(0) + ->setStatus('New'); + + $this->monitorRepository->getEntityManager()->persist($monitor); + $this->monitorRepository->getEntityManager()->flush(); + + $command = new MonitorTvEpisodeCommand($monitor->getId()); + $this->bus->dispatch($command); + $this->logger->info('> [MonitorTvSeasonHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle()); + } + } + + $monitor->setLastSearch(new DateTimeImmutable()); + $monitor->setSearchCount($monitor->getSearchCount() + 1); + $this->entityManager->flush(); + + return new MonitorMovieResult( + status: 'OK', + result: [ + 'monitor' => $monitor, + ] + ); + } +} diff --git a/src/Download/Action/Handler/MonitorTvShowHandler.php b/src/Download/Action/Handler/MonitorTvShowHandler.php new file mode 100644 index 0000000..0f06667 --- /dev/null +++ b/src/Download/Action/Handler/MonitorTvShowHandler.php @@ -0,0 +1,98 @@ + */ +readonly class MonitorTvShowHandler implements HandlerInterface +{ + public function __construct( + private MonitorRepository $monitorRepository, + private EntityManagerInterface $entityManager, + private MediaFiles $mediaFiles, + private MessageBusInterface $bus, + private LoggerInterface $logger, + private Tmdb $tmdb, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + $this->logger->info('> [MonitorTvShowHandler] Executing MonitorTvShowHandler'); + $monitor = $this->monitorRepository->find($command->monitorId); + + // Check current episodes + $downloadedEpisodes = $this->mediaFiles + ->getEpisodes($monitor->getTitle()) + ->map(fn($episode) => (object) (new PTN())->parse($episode)); + $this->logger->info('> [MonitorTvShowHandler] Found ' . count($downloadedEpisodes) . ' downloaded episodes for title: ' . $monitor->getTitle()); + + // Compare against list from TMDB + $episodesInShow = Map::from( + $this->tmdb->tvDetails($monitor->getTmdbId()) + ->episodes + )->flat(1); + $this->logger->info('> [MonitorTvShowHandler] Found ' . count($episodesInShow) . ' episodes in season ' . $monitor->getSeason() . ' for title: ' . $monitor->getTitle()); + + // Dispatch Episode commands for each missing Episode + foreach ($episodesInShow as $episode) { + $episodeAlreadyDownloaded = $downloadedEpisodes->find( + fn($ep) => $ep->episode === $episode['episode_number'] && $ep->season === $episode['season_number'] + ); + $episodeAlreadyDownloaded = !is_null($episodeAlreadyDownloaded); + + if (false === $episodeAlreadyDownloaded) { + $monitor = (new Monitor()) + ->setUser($monitor->getUser()) + ->setTmdbId($monitor->getTmdbId()) + ->setImdbId($monitor->getImdbId()) + ->setTitle($monitor->getTitle()) + ->setMonitorType('tvshow') + ->setSeason($episode['season_number']) + ->setEpisode($episode['episode_number']) + ->setCreatedAt(new DateTimeImmutable()) + ->setSearchCount(0) + ->setStatus('New'); + + $this->monitorRepository->getEntityManager()->persist($monitor); + $this->monitorRepository->getEntityManager()->flush(); + + $command = new MonitorTvEpisodeCommand($monitor->getId()); + $this->bus->dispatch($command); + $this->logger->info('> [MonitorTvShowHandler] Dispatching MonitorTvEpisodeCommand for season ' . $monitor->getSeason() . ' episode ' . $monitor->getEpisode() . ' for title: ' . $monitor->getTitle()); + } + } + + $monitor->setLastSearch(new DateTimeImmutable()); + $monitor->setSearchCount($monitor->getSearchCount() + 1); + $this->entityManager->flush(); + + return new MonitorTvEpisodeResult( + status: 'OK', + result: [ + 'monitor' => $monitor, + ] + ); + } +} diff --git a/src/Download/Action/Input/AddMonitorInput.php b/src/Download/Action/Input/AddMonitorInput.php new file mode 100644 index 0000000..5bc4dac --- /dev/null +++ b/src/Download/Action/Input/AddMonitorInput.php @@ -0,0 +1,49 @@ +userEmail, + $this->title, + $this->imdbId, + $this->tmdbId, + $this->monitorType, + $this->season, + $this->episode, + ); + } +} diff --git a/src/Download/Action/Input/AddMovieMonitorInput.php b/src/Download/Action/Input/AddMovieMonitorInput.php deleted file mode 100644 index 37d3f7f..0000000 --- a/src/Download/Action/Input/AddMovieMonitorInput.php +++ /dev/null @@ -1,31 +0,0 @@ -userEmail, $this->title, $this->imdbId, $this->tmdbId); - } -} diff --git a/src/Download/Action/Result/AddMovieMonitorResult.php b/src/Download/Action/Result/AddMonitorResult.php similarity index 78% rename from src/Download/Action/Result/AddMovieMonitorResult.php rename to src/Download/Action/Result/AddMonitorResult.php index 5f510a8..594c5ea 100644 --- a/src/Download/Action/Result/AddMovieMonitorResult.php +++ b/src/Download/Action/Result/AddMonitorResult.php @@ -4,7 +4,7 @@ namespace App\Download\Action\Result; use OneToMany\RichBundle\Contract\ResultInterface; -class AddMovieMonitorResult implements ResultInterface +class AddMonitorResult implements ResultInterface { public function __construct( public string $status, diff --git a/src/Download/Action/Result/MonitorTvEpisodeResult.php b/src/Download/Action/Result/MonitorTvEpisodeResult.php new file mode 100644 index 0000000..33dbfac --- /dev/null +++ b/src/Download/Action/Result/MonitorTvEpisodeResult.php @@ -0,0 +1,13 @@ +handle($input->toCommand()); + return $this->json([ + 'status' => 200, + 'message' => $response + ]); + } +} diff --git a/src/Download/Framework/Entity/MovieMonitor.php b/src/Download/Framework/Entity/Monitor.php similarity index 75% rename from src/Download/Framework/Entity/MovieMonitor.php rename to src/Download/Framework/Entity/Monitor.php index 18861dc..9eee59b 100644 --- a/src/Download/Framework/Entity/MovieMonitor.php +++ b/src/Download/Framework/Entity/Monitor.php @@ -2,13 +2,13 @@ namespace App\Download\Framework\Entity; -use App\Download\Framework\Repository\MovieMonitorRepository; +use App\Download\Framework\Repository\MonitorRepository; use App\User\Framework\Entity\User; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -#[ORM\Entity(repositoryClass: MovieMonitorRepository::class)] -class MovieMonitor +#[ORM\Entity(repositoryClass: MonitorRepository::class)] +class Monitor { #[ORM\Id] #[ORM\GeneratedValue] @@ -28,6 +28,15 @@ class MovieMonitor #[ORM\Column(length: 255)] private ?string $tmdbId = null; + #[ORM\Column(length: 255)] + private ?string $monitorType = null; + + #[ORM\Column(nullable: true)] + private ?int $season = null; + + #[ORM\Column(nullable: true)] + private ?int $episode = null; + #[ORM\Column(length: 255)] private ?string $status = null; @@ -155,4 +164,40 @@ class MovieMonitor return $this; } + + public function getMonitorType(): ?string + { + return $this->monitorType; + } + + public function setMonitorType(string $monitorType): static + { + $this->monitorType = $monitorType; + + return $this; + } + + public function getSeason(): ?int + { + return $this->season; + } + + public function setSeason(?int $season): static + { + $this->season = $season; + + return $this; + } + + public function getEpisode(): ?int + { + return $this->episode; + } + + public function setEpisode(?int $episode): static + { + $this->episode = $episode; + + return $this; + } } diff --git a/src/Download/Framework/Repository/MovieMonitorRepository.php b/src/Download/Framework/Repository/MonitorRepository.php similarity index 81% rename from src/Download/Framework/Repository/MovieMonitorRepository.php rename to src/Download/Framework/Repository/MonitorRepository.php index d58f05d..bae82eb 100644 --- a/src/Download/Framework/Repository/MovieMonitorRepository.php +++ b/src/Download/Framework/Repository/MonitorRepository.php @@ -2,18 +2,18 @@ namespace App\Download\Framework\Repository; -use App\Download\Framework\Entity\MovieMonitor; +use App\Download\Framework\Entity\Monitor; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends ServiceEntityRepository */ -class MovieMonitorRepository extends ServiceEntityRepository +class MonitorRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { - parent::__construct($registry, MovieMonitor::class); + parent::__construct($registry, Monitor::class); } // /** diff --git a/src/Download/Framework/Scheduler/MonitorDispatcher.php b/src/Download/Framework/Scheduler/MonitorDispatcher.php new file mode 100644 index 0000000..50fafe5 --- /dev/null +++ b/src/Download/Framework/Scheduler/MonitorDispatcher.php @@ -0,0 +1,39 @@ +logger->info('[MonitorDispatcher] Executing MovieMonitorDispatcher'); + + $monitorHandlers = [ + 'movie' => MonitorMovieHandler::class, + 'tvepisode' => MonitorTvSeasonHandler::class, + 'tvseason' => MonitorTvSeasonHandler::class, + 'tvshow' => MonitorTvSeasonHandler::class, + ]; + + $monitors = $this->monitorRepository->findBy(['status' => ['New', 'In Progress']]); + + foreach ($monitors as $monitor) { + $handler = $monitorHandlers[$monitor->getMonitorType()]; + $this->logger->info('[MonitorDispatcher] Dispatching ' . $handler . ' for ' . $monitor->getTitle()); + $this->bus->dispatch(new $handler($monitor->getId())); + } + } +} diff --git a/src/Download/Framework/Scheduler/MovieMonitorDispatcher.php b/src/Download/Framework/Scheduler/MovieMonitorDispatcher.php deleted file mode 100644 index 0b43876..0000000 --- a/src/Download/Framework/Scheduler/MovieMonitorDispatcher.php +++ /dev/null @@ -1,29 +0,0 @@ -logger->info('[MovieMonitorDispatcher] Executing MovieMonitorDispatcher'); - $monitors = $this->movieMonitorRepository->findBy(['status' => ['New', 'In Progress']]); - - foreach ($monitors as $monitor) { - $this->logger->info('[MovieMonitorDispatcher] Dispatching MovieMonitorCommand for ' . $monitor->getTitle()); - $this->bus->dispatch(new MonitorMovieCommand($monitor->getId())); - } - } -} diff --git a/src/Download/Service/MediaFiles.php b/src/Download/Service/MediaFiles.php new file mode 100644 index 0000000..c0a23f9 --- /dev/null +++ b/src/Download/Service/MediaFiles.php @@ -0,0 +1,76 @@ +finder = new Finder(); + $this->moviesPath = $moviesPath; + $this->tvShowsPath = $tvShowsPath; + } + + public function getMoviesPath(): string + { + return $this->moviesPath; + } + + public function getTvShowsPath(): string + { + return $this->tvShowsPath; + } + + public function getMovieDirs(): Map + { + $results = []; + foreach ($this->finder->in($this->moviesPath)->directories() as $file) { + $results[] = $file; + } + + return Map::from($results); + } + + public function getTvShowDirs(): Map + { + $results = []; + foreach ($this->finder->in($this->tvShowsPath)->directories() as $file) { + $results[] = $file; + } + + return Map::from($results); + } + + public function getEpisodes(string $path, bool $onlyFilenames = true): Map + { + if (!str_starts_with($path, $this->tvShowsPath)) { + $path = $this->tvShowsPath . DIRECTORY_SEPARATOR . $path; + } + + $results = []; + foreach ($this->finder->in($path)->files() as $file) { + if ($onlyFilenames) { + $results[] = $file->getRelativePathname(); + } else { + $results[] = $file; + } + } + + return Map::from($results); + } +} diff --git a/src/Download/Service/MonitorOptionEvaluator.php b/src/Download/Service/MonitorOptionEvaluator.php index bee492b..6083be8 100644 --- a/src/Download/Service/MonitorOptionEvaluator.php +++ b/src/Download/Service/MonitorOptionEvaluator.php @@ -3,18 +3,18 @@ namespace App\Download\Service; use Aimeos\Map; -use App\Download\Framework\Entity\MovieMonitor; +use App\Download\Framework\Entity\Monitor; use App\Torrentio\Result\TorrentioResult; class MonitorOptionEvaluator { /** - * @param MovieMonitor $monitor + * @param Monitor $monitor * @param TorrentioResult[] $results * @return TorrentioResult|null * @throws \Throwable */ - public function evaluateOptions(MovieMonitor $monitor, array $results): ?TorrentioResult + public function evaluateOptions(Monitor $monitor, array $results): ?TorrentioResult { $sizeLow = 500; $sizeHigh = 4096; diff --git a/src/Schedule.php b/src/Schedule.php index b35acf0..ef2141e 100644 --- a/src/Schedule.php +++ b/src/Schedule.php @@ -2,7 +2,7 @@ namespace App; -use App\Download\Framework\Scheduler\MovieMonitorDispatcher; +use App\Download\Framework\Scheduler\MonitorDispatcher; use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\Schedule as SymfonySchedule; use Symfony\Component\Scheduler\ScheduleProviderInterface; diff --git a/src/User/Framework/Entity/User.php b/src/User/Framework/Entity/User.php index 4048d34..ac151d8 100644 --- a/src/User/Framework/Entity/User.php +++ b/src/User/Framework/Entity/User.php @@ -3,7 +3,7 @@ namespace App\User\Framework\Entity; use Aimeos\Map; -use App\Download\Framework\Entity\MovieMonitor; +use App\Download\Framework\Entity\Monitor; use App\User\Framework\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -41,9 +41,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface private Collection $userPreferences; /** - * @var Collection + * @var Collection */ - #[ORM\OneToMany(targetEntity: MovieMonitor::class, mappedBy: 'user', orphanRemoval: true)] + #[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)] private Collection $yes; public function __construct() @@ -213,14 +213,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface } /** - * @return Collection + * @return Collection */ public function getYes(): Collection { return $this->yes; } - public function addYe(MovieMonitor $ye): static + public function addYe(Monitor $ye): static { if (!$this->yes->contains($ye)) { $this->yes->add($ye); @@ -230,7 +230,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } - public function removeYe(MovieMonitor $ye): static + public function removeYe(Monitor $ye): static { if ($this->yes->removeElement($ye)) { // set the owning side to null (unless already changed) diff --git a/symfony.lock b/symfony.lock index 150143d..50f27a4 100644 --- a/symfony.lock +++ b/symfony.lock @@ -41,6 +41,18 @@ "config/packages/http_discovery.yaml" ] }, + "phpstan/phpstan": { + "version": "2.1", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "1.0", + "ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767" + }, + "files": [ + "phpstan.dist.neon" + ] + }, "symfony/asset-mapper": { "version": "7.2", "recipe": {