feat: movie download monitor

This commit is contained in:
2025-05-03 09:34:40 -05:00
parent 993b34d668
commit babcb00440
13 changed files with 552 additions and 3 deletions

View File

@@ -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",

53
composer.lock generated
View File

@@ -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",

View File

@@ -0,0 +1,41 @@
<?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 Version20250503034641 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 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);
}
}

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,60 @@
<?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\MovieMonitorRepository;
use App\Download\Service\MonitorOptionEvaluator;
use App\Torrentio\Action\Command\GetMovieOptionsCommand;
use App\Torrentio\Action\Handler\GetMovieOptionsHandler;
use DateTimeImmutable;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\MessageBusInterface;
/** @implements HandlerInterface<MonitorMovieCommand> */
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,
]
);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Download\Framework\Entity;
use App\Download\Framework\Repository\MovieMonitorRepository;
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\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'yes')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 255)]
private ?string $imdbId = null;
#[ORM\Column(length: 255)]
private ?string $tmdbId = null;
#[ORM\Column(length: 255)]
private ?string $status = null;
#[ORM\Column(nullable: true)]
private ?int $searchCount = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $lastSearch = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $downloadedAt = null;
public function getId(): ?int
{
return $this->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;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\MovieMonitor;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<MovieMonitor>
*/
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()
// ;
// }
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Download\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\MovieMonitor;
use App\Torrentio\Result\TorrentioResult;
class MonitorOptionEvaluator
{
/**
* @param MovieMonitor $monitor
* @param TorrentioResult[] $results
* @return TorrentioResult|null
* @throws \Throwable
*/
public function evaluateOptions(MovieMonitor $monitor, array $results): ?TorrentioResult
{
$sizeLow = 500;
$sizeHigh = 4096;
$bestMatches = [];
$matches = [];
$userPreferences = $monitor->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;
}
}

View File

@@ -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 = [];

View File

@@ -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<int, MovieMonitor>
*/
#[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<int, MovieMonitor>
*/
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;
}
}