From babcb00440311c8bd358ac55202bdf4aea106f45 Mon Sep 17 00:00:00 2001 From: Brock H Caldwell Date: Sat, 3 May 2025 09:34:40 -0500 Subject: [PATCH] feat: movie download monitor --- composer.json | 1 + composer.lock | 53 +++++- migrations/Version20250503034641.php | 41 +++++ src/Controller/IndexController.php | 4 + .../Action/Command/MonitorMovieCommand.php | 13 ++ .../Action/Handler/MonitorMovieHandler.php | 60 +++++++ .../Action/Input/MonitorMovieInput.php | 24 +++ .../Action/Result/MonitorMovieResult.php | 13 ++ .../Framework/Entity/MovieMonitor.php | 158 ++++++++++++++++++ .../Repository/MovieMonitorRepository.php | 43 +++++ .../Service/MonitorOptionEvaluator.php | 91 ++++++++++ src/Torrentio/Result/ResultFactory.php | 15 +- src/User/Framework/Entity/User.php | 39 ++++- 13 files changed, 552 insertions(+), 3 deletions(-) create mode 100644 migrations/Version20250503034641.php create mode 100644 src/Download/Action/Command/MonitorMovieCommand.php create mode 100644 src/Download/Action/Handler/MonitorMovieHandler.php create mode 100644 src/Download/Action/Input/MonitorMovieInput.php create mode 100644 src/Download/Action/Result/MonitorMovieResult.php create mode 100644 src/Download/Framework/Entity/MovieMonitor.php create mode 100644 src/Download/Framework/Repository/MovieMonitorRepository.php create mode 100644 src/Download/Service/MonitorOptionEvaluator.php diff --git a/composer.json b/composer.json index ab72687..132aeb6 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "ext-iconv": "*", "1tomany/rich-bundle": "^1.8", "aimeos/map": "^3.12", + "carbondate/carbon": "*", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.14", "doctrine/doctrine-migrations-bundle": "^3.4", diff --git a/composer.lock b/composer.lock index 8270c8a..7c9b30a 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": "94d3dbf218bac7512ce9ced86ff066aa", + "content-hash": "909a198b48a473bd53de5b78a2abac1c", "packages": [ { "name": "1tomany/data-uri", @@ -175,6 +175,57 @@ }, "time": "2025-03-05T09:16:18+00:00" }, + { + "name": "carbondate/carbon", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonDate/Carbon.git", + "reference": "a1dd1ad9abfc8b3c4d8768068e6c71d293424e86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonDate/Carbon/zipball/a1dd1ad9abfc8b3c4d8768068e6c71d293424e86", + "reference": "a1dd1ad9abfc8b3c4d8768068e6c71d293424e86", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Carbon": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + } + ], + "description": "A simple API extension for DateTime.", + "homepage": "http://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "issues": "https://github.com/CarbonDate/Carbon/issues", + "source": "https://github.com/CarbonDate/Carbon/tree/1.17.0" + }, + "abandoned": true, + "time": "2015-03-08T14:05:44+00:00" + }, { "name": "composer/semver", "version": "3.4.3", diff --git a/migrations/Version20250503034641.php b/migrations/Version20250503034641.php new file mode 100644 index 0000000..00fae3d --- /dev/null +++ b/migrations/Version20250503034641.php @@ -0,0 +1,41 @@ +addSql(<<<'SQL' + CREATE TABLE movie_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)', INDEX IDX_C183DBABA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE movie_monitor ADD CONSTRAINT FK_C183DBABA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE movie_monitor DROP FOREIGN KEY FK_C183DBABA76ED395 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE movie_monitor + SQL); + } +} diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index 1f0e129..dddd0b4 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -2,7 +2,11 @@ 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\Repository\DownloadRepository; +use App\Download\Framework\Repository\MovieMonitorRepository; use App\Tmdb\Tmdb; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; diff --git a/src/Download/Action/Command/MonitorMovieCommand.php b/src/Download/Action/Command/MonitorMovieCommand.php new file mode 100644 index 0000000..ec09a74 --- /dev/null +++ b/src/Download/Action/Command/MonitorMovieCommand.php @@ -0,0 +1,13 @@ + */ +readonly class MonitorMovieHandler implements HandlerInterface +{ + public function __construct( + private MovieMonitorRepository $movieMonitorRepository, + private GetMovieOptionsHandler $getMovieOptionsHandler, + private MonitorOptionEvaluator $monitorOptionEvaluator, + private MessageBusInterface $bus, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + $monitor = $this->movieMonitorRepository->find($command->movieMonitorId); + $results = $this->getMovieOptionsHandler->handle( + new GetMovieOptionsCommand($monitor->getTmdbId(), $monitor->getImdbId()) + ); + + $result = $this->monitorOptionEvaluator->evaluateOptions($monitor, $results->results); + + if (null !== $result) { + $this->bus->dispatch(new DownloadMediaCommand( + $result->url, + $result->title, + $result->filename, + 'movies', + $monitor->getImdbId(), + )); + $monitor->setStatus('Complete'); + $monitor->setDownloadedAt(new DateTimeIMmutable()); + } + + $monitor->setLastSearch(new DateTimeImmutable()); + $monitor->setSearchCount($monitor->getSearchCount() + 1); + $this->movieMonitorRepository->getEntityManager()->flush(); + + return new MonitorMovieResult( + status: 'OK', + result: [ + 'monitor' => $monitor, + ] + ); + } +} diff --git a/src/Download/Action/Input/MonitorMovieInput.php b/src/Download/Action/Input/MonitorMovieInput.php new file mode 100644 index 0000000..63b3f27 --- /dev/null +++ b/src/Download/Action/Input/MonitorMovieInput.php @@ -0,0 +1,24 @@ +tmdbId, $this->imdbId); + } +} diff --git a/src/Download/Action/Result/MonitorMovieResult.php b/src/Download/Action/Result/MonitorMovieResult.php new file mode 100644 index 0000000..766a7aa --- /dev/null +++ b/src/Download/Action/Result/MonitorMovieResult.php @@ -0,0 +1,13 @@ +id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(?string $title): static + { + $this->title = $title; + + return $this; + } + + public function getImdbId(): ?string + { + return $this->imdbId; + } + + public function setImdbId(string $imdbId): static + { + $this->imdbId = $imdbId; + + return $this; + } + + public function getTmdbId(): ?string + { + return $this->tmdbId; + } + + public function setTmdbId(string $tmdbId): static + { + $this->tmdbId = $tmdbId; + + return $this; + } + + public function getStatus(): ?string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getSearchCount(): ?int + { + return $this->searchCount; + } + + public function setSearchCount(?int $searchCount): static + { + $this->searchCount = $searchCount; + + return $this; + } + + public function getLastSearch(): ?\DateTimeInterface + { + return $this->lastSearch; + } + + public function setLastSearch(?\DateTimeInterface $lastSearch): static + { + $this->lastSearch = $lastSearch; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getDownloadedAt(): ?\DateTimeImmutable + { + return $this->downloadedAt; + } + + public function setDownloadedAt(?\DateTimeImmutable $downloadedAt): static + { + $this->downloadedAt = $downloadedAt; + + return $this; + } +} diff --git a/src/Download/Framework/Repository/MovieMonitorRepository.php b/src/Download/Framework/Repository/MovieMonitorRepository.php new file mode 100644 index 0000000..d58f05d --- /dev/null +++ b/src/Download/Framework/Repository/MovieMonitorRepository.php @@ -0,0 +1,43 @@ + + */ +class MovieMonitorRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, MovieMonitor::class); + } + +// /** +// * @return MovieMonitor[] Returns an array of MovieMonitor objects +// */ +// public function findByExampleField($value): array +// { +// return $this->createQueryBuilder('m') +// ->andWhere('m.exampleField = :val') +// ->setParameter('val', $value) +// ->orderBy('m.id', 'ASC') +// ->setMaxResults(10) +// ->getQuery() +// ->getResult() +// ; +// } + +// public function findOneBySomeField($value): ?MovieMonitor +// { +// return $this->createQueryBuilder('m') +// ->andWhere('m.exampleField = :val') +// ->setParameter('val', $value) +// ->getQuery() +// ->getOneOrNullResult() +// ; +// } +} diff --git a/src/Download/Service/MonitorOptionEvaluator.php b/src/Download/Service/MonitorOptionEvaluator.php new file mode 100644 index 0000000..6387496 --- /dev/null +++ b/src/Download/Service/MonitorOptionEvaluator.php @@ -0,0 +1,91 @@ +getUser()->getUserPreferenceValues(); + + foreach ($results as $result) { + if (!in_array($userPreferences['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; + } + } + + $sizeMatches = []; + + foreach ($bestMatches 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(); + } + + 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/Torrentio/Result/ResultFactory.php b/src/Torrentio/Result/ResultFactory.php index 5a8bba7..fe4d4cc 100644 --- a/src/Torrentio/Result/ResultFactory.php +++ b/src/Torrentio/Result/ResultFactory.php @@ -8,6 +8,14 @@ use Nihilarr\PTN; class ResultFactory { + public static $codecMap = [ + 'h264' => 'h264', + 'h265' => 'h265', + 'x264' => 'h264', + 'x265' => 'h265', + '-' => '-' + ]; + public static function map( string $url, string $title, @@ -25,7 +33,7 @@ class ResultFactory $ptn->season ?? "-", $bingeGroup, $ptn->resolution ?? "-", - $ptn->codec ?? "-", + self::setCodec($ptn->codec ?? "-"), $ptn, substr(base64_encode($url), strlen($url) - 10), $ptn->episode ?? "-", @@ -101,6 +109,11 @@ class ResultFactory } } + public static function setCodec(string $codec): string + { + return self::$codecMap[strtolower($codec)] ?? $codec; + } + private static function setEpisode(string $title) { $value = []; diff --git a/src/User/Framework/Entity/User.php b/src/User/Framework/Entity/User.php index 233984a..4048d34 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\User\Framework\Repository\PreferencesRepository; +use App\Download\Framework\Entity\MovieMonitor; use App\User\Framework\Repository\UserRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -40,9 +40,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist', 'remove'])] private Collection $userPreferences; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: MovieMonitor::class, mappedBy: 'user', orphanRemoval: true)] + private Collection $yes; + public function __construct() { $this->userPreferences = new ArrayCollection(); + $this->yes = new ArrayCollection(); } public function getId(): ?int @@ -204,4 +211,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface }) ->toArray(); } + + /** + * @return Collection + */ + public function getYes(): Collection + { + return $this->yes; + } + + public function addYe(MovieMonitor $ye): static + { + if (!$this->yes->contains($ye)) { + $this->yes->add($ye); + $ye->setUser($this); + } + + return $this; + } + + public function removeYe(MovieMonitor $ye): static + { + if ($this->yes->removeElement($ye)) { + // set the owning side to null (unless already changed) + if ($ye->getUser() === $this) { + $ye->setUser(null); + } + } + + return $this; + } }