feat: movie monitoring

This commit is contained in:
2025-05-03 23:55:31 -05:00
parent 5688b3a0df
commit 9166b4bbc8
16 changed files with 477 additions and 353 deletions

View File

@@ -5,3 +5,4 @@ DOWNLOAD_DIR=./movies
MERCURE_URL=http://mercure/.well-known/mercure MERCURE_URL=http://mercure/.well-known/mercure
MERCURE_PUBLIC_URL=https://dev.caldwell.digital/hub/.well-known/mercure MERCURE_PUBLIC_URL=https://dev.caldwell.digital/hub/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"
MONITOR_FREQUENCY="* * * * *"

View File

@@ -31,6 +31,13 @@ services:
- ./var/download:/var/download - ./var/download:/var/download
command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10 command: php ./bin/console messenger:consume async -v --time-limit=3600 --limit=10
scheduler:
build: .
volumes:
- ./:/var/www
- ./var/download:/var/download
command: php ./bin/console messenger:consume scheduler_movie_monitor -v --time-limit=3600
mercure: mercure:
image: dunglas/mercure image: dunglas/mercure
restart: unless-stopped restart: unless-stopped

View File

@@ -9,11 +9,11 @@
"ext-iconv": "*", "ext-iconv": "*",
"1tomany/rich-bundle": "^1.8", "1tomany/rich-bundle": "^1.8",
"aimeos/map": "^3.12", "aimeos/map": "^3.12",
"carbondate/carbon": "*",
"doctrine/dbal": "^3", "doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14", "doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4", "doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"dragonmantank/cron-expression": "^3.4",
"nihilarr/parse-torrent-name": "^0.0.1", "nihilarr/parse-torrent-name": "^0.0.1",
"nyholm/psr7": "*", "nyholm/psr7": "*",
"p3k/emoji-detector": "^1.2", "p3k/emoji-detector": "^1.2",
@@ -29,6 +29,7 @@
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
"symfony/messenger": "7.2.*", "symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*", "symfony/runtime": "7.2.*",
"symfony/scheduler": "7.2.*",
"symfony/security-bundle": "7.2.*", "symfony/security-bundle": "7.2.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",
"symfony/twig-bundle": "7.2.*", "symfony/twig-bundle": "7.2.*",

583
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,19 @@
framework: framework:
messenger: messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling. # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed failure_transport: failed
transports: transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration # https://symfony.com/doc/current/messenger.html#transport-configuration
async: '%env(MESSENGER_TRANSPORT_DSN)%' async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
use_notify: true
check_delayed_interval: 60000
retry_strategy:
max_retries: 1
multiplier: 1
failed: 'doctrine://default?queue_name=failed' failed: 'doctrine://default?queue_name=failed'
default_bus: messenger.bus.default default_bus: messenger.bus.default

View File

@@ -2,8 +2,9 @@
namespace App\Controller; namespace App\Controller;
use App\Download\Action\Command\AddMovieMonitorCommand; use App\Download\Action\Command\MonitorMovieCommand;
use App\Download\Action\Handler\AddMovieMonitorHandler; use App\Download\Action\Handler\AddMovieMonitorHandler;
use App\Download\Action\Handler\MonitorMovieHandler;
use App\Download\Action\Input\AddMovieMonitorInput; use App\Download\Action\Input\AddMovieMonitorInput;
use App\Download\Action\Input\DownloadMediaInput; use App\Download\Action\Input\DownloadMediaInput;
use App\Download\Framework\Repository\DownloadRepository; use App\Download\Framework\Repository\DownloadRepository;
@@ -19,6 +20,18 @@ class DownloadController extends AbstractController
private MessageBusInterface $bus, private MessageBusInterface $bus,
) {} ) {}
#[Route('/test', name: 'app_test')]
public function test(
MonitorMovieHandler $handler,
) {
$command = new MonitorMovieCommand(41);
$handler->handle($command);
return $this->json([
'status' => 200,
'message' => $command
]);
}
#[Route('/download', name: 'app_download', methods: ['POST'])] #[Route('/download', name: 'app_download', methods: ['POST'])]
public function download( public function download(
DownloadMediaInput $input, DownloadMediaInput $input,

View File

@@ -2,7 +2,6 @@
namespace App\Download\Action\Command; namespace App\Download\Action\Command;
use App\Download\Framework\Entity\MovieMonitor;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
class MonitorMovieCommand implements CommandInterface class MonitorMovieCommand implements CommandInterface

View File

@@ -10,9 +10,11 @@ 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;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface; use OneToMany\RichBundle\Contract\ResultInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */ /** @implements HandlerInterface<MonitorMovieCommand> */
@@ -22,22 +24,31 @@ readonly class MonitorMovieHandler implements HandlerInterface
private MovieMonitorRepository $movieMonitorRepository, private MovieMonitorRepository $movieMonitorRepository,
private GetMovieOptionsHandler $getMovieOptionsHandler, private GetMovieOptionsHandler $getMovieOptionsHandler,
private MonitorOptionEvaluator $monitorOptionEvaluator, private MonitorOptionEvaluator $monitorOptionEvaluator,
private EntityManagerInterface $entityManager,
private MessageBusInterface $bus, private MessageBusInterface $bus,
private LoggerInterface $logger,
) {} ) {}
public function handle(CommandInterface $command): ResultInterface public function handle(CommandInterface $command): ResultInterface
{ {
$this->logger->info('> [MonitorMovieHandler] Executing MonitorMovieHandler');
$monitor = $this->movieMonitorRepository->find($command->movieMonitorId); $monitor = $this->movieMonitorRepository->find($command->movieMonitorId);
$monitor->setStatus('In Progress');
$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())
); );
$this->logger->info('> [MonitorMovieHandler] Found ' . count($results->results) . ' download options');
$result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results); $result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results);
if (null !== $result) { if (null !== $result) {
$this->logger->info('> [MonitorMovieHandler] 1 result found: dispatching DownloadMediaCommand for "' . $result->title . '"');
$this->bus->dispatch(new DownloadMediaCommand( $this->bus->dispatch(new DownloadMediaCommand(
$result->url, $result->url,
$result->title, $monitor->getTitle(),
$result->filename, $result->filename,
'movies', 'movies',
$monitor->getImdbId(), $monitor->getImdbId(),
@@ -48,7 +59,7 @@ readonly class MonitorMovieHandler implements HandlerInterface
$monitor->setLastSearch(new DateTimeImmutable()); $monitor->setLastSearch(new DateTimeImmutable());
$monitor->setSearchCount($monitor->getSearchCount() + 1); $monitor->setSearchCount($monitor->getSearchCount() + 1);
$this->movieMonitorRepository->getEntityManager()->flush(); $this->entityManager->flush();
return new MonitorMovieResult( return new MonitorMovieResult(
status: 'OK', status: 'OK',

View File

@@ -14,18 +14,18 @@ class AddMovieMonitorInput implements InputInterface
#[SourceSecurity] #[SourceSecurity]
public string $userEmail, public string $userEmail,
#[SourceRoute('title')]
public string $title,
#[SourceRoute('tmdbId')] #[SourceRoute('tmdbId')]
public string $tmdbId, public string $tmdbId,
#[SourceRoute('imdbId')] #[SourceRoute('imdbId')]
public string $imdbId, public string $imdbId,
#[SourceRoute('title')]
public string $title,
) {} ) {}
public function toCommand(): CommandInterface public function toCommand(): CommandInterface
{ {
return new AddMovieMonitorCommand($this->userEmail, $this->title, $this->tmdbId, $this->imdbId); return new AddMovieMonitorCommand($this->userEmail, $this->title, $this->imdbId, $this->tmdbId);
} }
} }

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Download\Framework\MessageHandler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Handler\DownloadMediaHandler;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(handles: DownloadMediaCommand::class)]
class DownloadMediaMessageHandler
{
public function __construct(
private DownloadMediaHandler $downloadMediaHandler,
) {}
public function __invoke(DownloadMediaCommand $command)
{
$this->downloadMediaHandler->handle($command);
}
}

View File

@@ -0,0 +1,29 @@
<?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

@@ -53,7 +53,6 @@ class MonitorOptionEvaluator
$sizeMatches = []; $sizeMatches = [];
foreach ($bestMatches as $result) { foreach ($bestMatches as $result) {
$size = 0;
if (str_contains($result->size, 'GB')) { if (str_contains($result->size, 'GB')) {
$size = (int) trim(str_replace('GB', '', $result->size)) * 1024; $size = (int) trim(str_replace('GB', '', $result->size)) * 1024;
} else { } else {

29
src/Schedule.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App;
use App\Download\Framework\Scheduler\MovieMonitorDispatcher;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Tmdb; namespace App\Tmdb;
use App\Enum\MediaType; use App\Enum\MediaType;
use App\Tmdb\TmdbResult;
use App\ValueObject\ResultFactory; use App\ValueObject\ResultFactory;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -18,12 +17,8 @@ use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener; use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\Listener\RequestListener; use Tmdb\Event\Listener\RequestListener;
use Tmdb\Event\RequestEvent; use Tmdb\Event\RequestEvent;
use Tmdb\Model\AbstractModel;
use Tmdb\Model\Image;
use Tmdb\Model\Movie; use Tmdb\Model\Movie;
use Tmdb\Model\Movie\QueryParameter\AppendToResponse;
use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery; use Tmdb\Model\Search\SearchQuery\KeywordSearchQuery;
use Tmdb\Model\Search\SearchQuery\MovieSearchQuery;
use Tmdb\Model\Tv; use Tmdb\Model\Tv;
use Tmdb\Repository\MovieRepository; use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository; use Tmdb\Repository\SearchRepository;
@@ -210,7 +205,19 @@ class Tmdb
throw new \Exception("A media type must be set when parsing from an array."); throw new \Exception("A media type must be set when parsing from an array.");
} }
function parseTvShow(array $data, string $posterBasePath): TmdbResult { 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
{
return new TmdbResult( return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'], imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'], tmdbId: $data['id'],
@@ -223,7 +230,8 @@ class Tmdb
); );
} }
function parseEpisode(array $data, string $posterBasePath): TmdbResult { private function parseEpisode(array $data, string $posterBasePath): TmdbResult
{
return new TmdbResult( return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'], imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'], tmdbId: $data['id'],
@@ -236,7 +244,8 @@ class Tmdb
); );
} }
function parseMovie(array $data, string $posterBasePath): TmdbResult { private function parseMovie(array $data, string $posterBasePath): TmdbResult
{
return new TmdbResult( return new TmdbResult(
imdbId: $data['external_ids']['imdb_id'], imdbId: $data['external_ids']['imdb_id'],
tmdbId: $data['id'], tmdbId: $data['id'],
@@ -248,16 +257,6 @@ class Tmdb
); );
} }
if ($mediaType === 'movie') {
$result = parseMovie($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'tvshow') {
$result = parseTvShow($data, self::POSTER_IMG_PATH);
} elseif ($mediaType === 'episode') {
$result = parseEpisode($data, self::POSTER_IMG_PATH);
}
return $result;
}
private function parseFromObject($result): TmdbResult private function parseFromObject($result): TmdbResult
{ {

View File

@@ -158,6 +158,18 @@
"config/routes.yaml" "config/routes.yaml"
] ]
}, },
"symfony/scheduler": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "caea3c928ee9e1b21288fd76aef36f16ea355515"
},
"files": [
"src/Schedule.php"
]
},
"symfony/security-bundle": { "symfony/security-bundle": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {

View File

@@ -16,9 +16,8 @@
</h3> </h3>
<button class="px-1.5 py-1 bg-green-600 text-white rounded-md cursor-pointer" <button class="px-1.5 py-1 bg-green-600 text-white rounded-md cursor-pointer"
{{ stimulus_controller('monitor', { {{ stimulus_controller('monitor', {
mediaType: results.media.mediaType,
imdbId: results.media.imdbId,
tmdbId: results.media.tmdbId, tmdbId: results.media.tmdbId,
imdbId: results.media.imdbId,
title: results.media.title title: results.media.title
}) }} }) }}
{% if results.media.mediaType == "movies" %} {% if results.media.mediaType == "movies" %}