wip-feat: dispatches monitor commands for episodes, seasons, & shows

This commit is contained in:
2025-05-06 00:00:45 -05:00
parent 9166b4bbc8
commit 527adb73c1
33 changed files with 795 additions and 147 deletions

4
.gitignore vendored
View File

@@ -13,3 +13,7 @@
/public/assets/ /public/assets/
/assets/vendor/ /assets/vendor/
###< symfony/asset-mapper ### ###< symfony/asset-mapper ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###

View File

@@ -23,6 +23,7 @@
"symfony/console": "7.2.*", "symfony/console": "7.2.*",
"symfony/doctrine-messenger": "7.2.*", "symfony/doctrine-messenger": "7.2.*",
"symfony/dotenv": "7.2.*", "symfony/dotenv": "7.2.*",
"symfony/finder": "7.2.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.2.*", "symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*", "symfony/framework-bundle": "7.2.*",
@@ -95,6 +96,7 @@
} }
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^2.1",
"symfony/maker-bundle": "^1.62", "symfony/maker-bundle": "^1.62",
"symfony/stopwatch": "7.2.*", "symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*" "symfony/web-profiler-bundle": "7.2.*"

60
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "97689e103d8e0ba79aba71891384895d", "content-hash": "1acedc6a795947368d0673ec79564bec",
"packages": [ "packages": [
{ {
"name": "1tomany/rich-bundle", "name": "1tomany/rich-bundle",
@@ -8488,6 +8488,64 @@
}, },
"time": "2024-12-30T11:07:19+00:00" "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", "name": "symfony/maker-bundle",
"version": "v1.63.0", "version": "v1.63.0",

View File

@@ -25,6 +25,7 @@ framework:
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async
'App\Download\Action\Command\DownloadMediaCommand': async 'App\Download\Action\Command\DownloadMediaCommand': async
'App\Download\Action\Command\MonitorTvEpisodeCommand': async
# when@test: # when@test:
# framework: # framework:

View File

@@ -4,6 +4,10 @@
# Put parameters here that don't need to change on each machine where the app is deployed # 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 # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: 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: services:
# default configuration for services in *this* file # default configuration for services in *this* file

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250505211458 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

8
phpstan.dist.neon Normal file
View File

@@ -0,0 +1,8 @@
parameters:
level: 6
paths:
- bin/
- config/
- public/
- src/
- tests/

View File

@@ -3,11 +3,18 @@
namespace App\Controller; namespace App\Controller;
use App\Download\Action\Command\MonitorMovieCommand; 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\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\Action\Input\DownloadMediaInput;
use App\Download\Framework\Entity\Monitor;
use App\Download\Framework\Repository\DownloadRepository; 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\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
@@ -18,13 +25,28 @@ class DownloadController extends AbstractController
public function __construct( public function __construct(
private DownloadRepository $downloadRepository, private DownloadRepository $downloadRepository,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private readonly MonitorRepository $monitorRepository,
) {} ) {}
#[Route('/test', name: 'app_test')] #[Route('/test', name: 'app_test')]
public function 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); $handler->handle($command);
return $this->json([ return $this->json([
'status' => 200, 'status' => 200,
@@ -53,16 +75,4 @@ class DownloadController extends AbstractController
return $this->json(['status' => 200, 'message' => 'Added to Queue']); 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
]);
}
} }

View File

@@ -4,9 +4,9 @@ namespace App\Controller;
use App\Download\Action\Command\MonitorMovieCommand; use App\Download\Action\Command\MonitorMovieCommand;
use App\Download\Action\Handler\MonitorMovieHandler; 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\DownloadRepository;
use App\Download\Framework\Repository\MovieMonitorRepository; use App\Download\Framework\Repository\MonitorRepository;
use App\Tmdb\Tmdb; use App\Tmdb\Tmdb;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;

View File

@@ -2,15 +2,18 @@
namespace App\Download\Action\Command; namespace App\Download\Action\Command;
use App\Download\Framework\Entity\MovieMonitor; use App\Download\Framework\Entity\Monitor;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
class AddMovieMonitorCommand implements CommandInterface class AddMonitorCommand implements CommandInterface
{ {
public function __construct( public function __construct(
public string $userEmail, public string $userEmail,
public string $title, public string $title,
public string $imdbId, public string $imdbId,
public string $tmdbId, public string $tmdbId,
public string $monitorType,
public ?int $season,
public ?int $episode,
) {} ) {}
} }

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvEpisodeCommand implements CommandInterface
{
public function __construct(
public int $movieMonitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvSeasonCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorTvShowCommand implements CommandInterface
{
public function __construct(
public int $monitorId,
) {}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\AddMonitorCommand;
use App\Download\Action\Result\AddMonitorResult;
use App\Download\Action\Result\MonitorMovieResult;
use App\Download\Framework\Entity\Monitor;
use App\Download\Framework\Repository\MonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<AddMonitorCommand> */
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,
]
);
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\AddMovieMonitorCommand;
use App\Download\Action\Result\MonitorMovieResult;
use App\Download\Framework\Entity\MovieMonitor;
use App\Download\Framework\Repository\MovieMonitorRepository;
use App\User\Framework\Repository\UserRepository;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements HandlerInterface<AddMovieMonitorCommand> */
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,
]
);
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\MonitorMovieCommand; use App\Download\Action\Command\MonitorMovieCommand;
use App\Download\Action\Result\MonitorMovieResult; 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\Download\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand; use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler; use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
@@ -21,7 +21,7 @@ use Symfony\Component\Messenger\MessageBusInterface;
readonly class MonitorMovieHandler implements HandlerInterface readonly class MonitorMovieHandler implements HandlerInterface
{ {
public function __construct( public function __construct(
private MovieMonitorRepository $movieMonitorRepository, private MonitorRepository $movieMonitorRepository,
private GetMovieOptionsHandler $getMovieOptionsHandler, private GetMovieOptionsHandler $getMovieOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator, private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
@@ -36,7 +36,6 @@ readonly class MonitorMovieHandler implements HandlerInterface
$monitor->setStatus('In Progress'); $monitor->setStatus('In Progress');
$this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options'); $this->logger->info('> [MonitorMovieHandler] Searching for "' . $monitor->getTitle() . '" download options');
$results = $this->getMovieOptionsHandler->handle( $results = $this->getMovieOptionsHandler->handle(
new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId()) new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId())
); );

View File

@@ -0,0 +1,76 @@
<?php
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\MonitorRepository;
use App\Download\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
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,
]
);
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\MonitorMovieCommand;
use App\Download\Action\Command\MonitorTvEpisodeCommand;
use App\Download\Action\Command\MonitorTvSeasonCommand;
use App\Download\Action\Result\MonitorMovieResult;
use App\Download\Framework\Entity\Monitor;
use App\Download\Framework\Repository\MonitorRepository;
use App\Download\Service\MediaFiles;
use App\Download\Service\MonitorOptionEvaluator;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
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,
]
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Download\Action\Handler;
use Aimeos\Map;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Command\MonitorMovieCommand;
use App\Download\Action\Command\MonitorTvEpisodeCommand;
use App\Download\Action\Command\MonitorTvSeasonCommand;
use App\Download\Action\Result\MonitorMovieResult;
use App\Download\Action\Result\MonitorTvEpisodeResult;
use App\Download\Framework\Entity\Monitor;
use App\Download\Framework\Repository\MonitorRepository;
use App\Download\Service\MediaFiles;
use App\Download\Service\MonitorOptionEvaluator;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Nihilarr\PTN;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
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,
]
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\AddMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class AddMonitorInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public string $userEmail,
#[SourceRequest('tmdbId')]
public string $tmdbId,
#[SourceRequest('imdbId')]
public string $imdbId,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('monitorType')]
public string $monitorType,
#[SourceRequest('season', nullify: true)]
public ?int $season,
#[SourceRequest('episode', nullify: true)]
public ?int $episode,
) {}
public function toCommand(): CommandInterface
{
return new AddMonitorCommand(
$this->userEmail,
$this->title,
$this->imdbId,
$this->tmdbId,
$this->monitorType,
$this->season,
$this->episode,
);
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\AddMovieMonitorCommand;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Attribute\SourceSecurity;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
class AddMovieMonitorInput implements InputInterface
{
public function __construct(
#[SourceSecurity]
public string $userEmail,
#[SourceRoute('tmdbId')]
public string $tmdbId,
#[SourceRoute('imdbId')]
public string $imdbId,
#[SourceRoute('title')]
public string $title,
) {}
public function toCommand(): CommandInterface
{
return new AddMovieMonitorCommand($this->userEmail, $this->title, $this->imdbId, $this->tmdbId);
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
class AddMovieMonitorResult implements ResultInterface class AddMonitorResult implements ResultInterface
{ {
public function __construct( public function __construct(
public string $status, public string $status,

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class MonitorTvEpisodeResult implements ResultInterface
{
public function __construct(
public string $status,
public array $result,
) {}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Download\Framework\Controller;
use App\Download\Action\Handler\AddMonitorHandler;
use App\Download\Action\Input\AddMonitorInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;
class ApiController extends AbstractController
{
#[Route('/monitor', name: 'app_add_movie_monitor', methods: ['POST'])]
public function addMonitor(
AddMonitorInput $input,
AddMonitorHandler $handler,
) {
$response = $handler->handle($input->toCommand());
return $this->json([
'status' => 200,
'message' => $response
]);
}
}

View File

@@ -2,13 +2,13 @@
namespace App\Download\Framework\Entity; namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\MovieMonitorRepository; use App\Download\Framework\Repository\MonitorRepository;
use App\User\Framework\Entity\User; use App\User\Framework\Entity\User;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MovieMonitorRepository::class)] #[ORM\Entity(repositoryClass: MonitorRepository::class)]
class MovieMonitor class Monitor
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -28,6 +28,15 @@ class MovieMonitor
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
private ?string $tmdbId = null; 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)] #[ORM\Column(length: 255)]
private ?string $status = null; private ?string $status = null;
@@ -155,4 +164,40 @@ class MovieMonitor
return $this; 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;
}
} }

View File

@@ -2,18 +2,18 @@
namespace App\Download\Framework\Repository; 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\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<MovieMonitor> * @extends ServiceEntityRepository<Monitor>
*/ */
class MovieMonitorRepository extends ServiceEntityRepository class MonitorRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
parent::__construct($registry, MovieMonitor::class); parent::__construct($registry, Monitor::class);
} }
// /** // /**

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Download\Framework\Scheduler;
use App\Download\Action\Handler\MonitorMovieHandler;
use App\Download\Action\Handler\MonitorTvSeasonHandler;
use App\Download\Framework\Repository\MonitorRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('* * * * *', schedule: 'monitor')]
class MonitorDispatcher
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly MonitorRepository $monitorRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke() {
$this->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()));
}
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Download\Framework\Scheduler;
use App\Download\Action\Command\MonitorMovieCommand;
use App\Download\Framework\Repository\MovieMonitorRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Scheduler\Attribute\AsCronTask;
#[AsCronTask('* * * * *', schedule: 'movie_monitor')]
class MovieMonitorDispatcher
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly MovieMonitorRepository $movieMonitorRepository,
private readonly MessageBusInterface $bus,
) {}
public function __invoke() {
$this->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()));
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Download\Service;
use Aimeos\Map;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Finder\Finder;
class MediaFiles
{
private Finder $finder;
private string $moviesPath;
private string $tvShowsPath;
public function __construct(
#[Autowire(param: 'media.movies_path')]
string $moviesPath,
#[Autowire(param: 'media.tvshows_path')]
string $tvShowsPath,
) {
$this->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);
}
}

View File

@@ -3,18 +3,18 @@
namespace App\Download\Service; namespace App\Download\Service;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\MovieMonitor; use App\Download\Framework\Entity\Monitor;
use App\Torrentio\Result\TorrentioResult; use App\Torrentio\Result\TorrentioResult;
class MonitorOptionEvaluator class MonitorOptionEvaluator
{ {
/** /**
* @param MovieMonitor $monitor * @param Monitor $monitor
* @param TorrentioResult[] $results * @param TorrentioResult[] $results
* @return TorrentioResult|null * @return TorrentioResult|null
* @throws \Throwable * @throws \Throwable
*/ */
public function evaluateOptions(MovieMonitor $monitor, array $results): ?TorrentioResult public function evaluateOptions(Monitor $monitor, array $results): ?TorrentioResult
{ {
$sizeLow = 500; $sizeLow = 500;
$sizeHigh = 4096; $sizeHigh = 4096;

View File

@@ -2,7 +2,7 @@
namespace App; namespace App;
use App\Download\Framework\Scheduler\MovieMonitorDispatcher; use App\Download\Framework\Scheduler\MonitorDispatcher;
use Symfony\Component\Scheduler\Attribute\AsSchedule; use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule; use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface; use Symfony\Component\Scheduler\ScheduleProviderInterface;

View File

@@ -3,7 +3,7 @@
namespace App\User\Framework\Entity; namespace App\User\Framework\Entity;
use Aimeos\Map; use Aimeos\Map;
use App\Download\Framework\Entity\MovieMonitor; use App\Download\Framework\Entity\Monitor;
use App\User\Framework\Repository\UserRepository; use App\User\Framework\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -41,9 +41,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private Collection $userPreferences; private Collection $userPreferences;
/** /**
* @var Collection<int, MovieMonitor> * @var Collection<int, Monitor>
*/ */
#[ORM\OneToMany(targetEntity: MovieMonitor::class, mappedBy: 'user', orphanRemoval: true)] #[ORM\OneToMany(targetEntity: Monitor::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $yes; private Collection $yes;
public function __construct() public function __construct()
@@ -213,14 +213,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
} }
/** /**
* @return Collection<int, MovieMonitor> * @return Collection<int, Monitor>
*/ */
public function getYes(): Collection public function getYes(): Collection
{ {
return $this->yes; return $this->yes;
} }
public function addYe(MovieMonitor $ye): static public function addYe(Monitor $ye): static
{ {
if (!$this->yes->contains($ye)) { if (!$this->yes->contains($ye)) {
$this->yes->add($ye); $this->yes->add($ye);
@@ -230,7 +230,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function removeYe(MovieMonitor $ye): static public function removeYe(Monitor $ye): static
{ {
if ($this->yes->removeElement($ye)) { if ($this->yes->removeElement($ye)) {
// set the owning side to null (unless already changed) // set the owning side to null (unless already changed)

View File

@@ -41,6 +41,18 @@
"config/packages/http_discovery.yaml" "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": { "symfony/asset-mapper": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {