wip-feat: adds download message queue logic

This commit is contained in:
2025-04-23 14:36:44 -05:00
parent 31d1b20045
commit a5c827b48f
36 changed files with 2644 additions and 165 deletions

17
.env
View File

@@ -18,3 +18,20 @@
APP_ENV=dev APP_ENV=dev
APP_SECRET= APP_SECRET=
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

View File

@@ -1 +1,18 @@
DATABASE_URL="%%db_url%%" DATABASE_URL="%%db_url%%"
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ###
###> symfony/messenger ###
# Choose one of the transports below
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
###< symfony/messenger ###

View File

@@ -8,6 +8,10 @@
"ext-ctype": "*", "ext-ctype": "*",
"ext-iconv": "*", "ext-iconv": "*",
"1tomany/rich-bundle": "^1.8", "1tomany/rich-bundle": "^1.8",
"doctrine/dbal": "^3",
"doctrine/doctrine-bundle": "^2.14",
"doctrine/doctrine-migrations-bundle": "^3.4",
"doctrine/orm": "^3.3",
"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",
@@ -16,7 +20,9 @@
"symfony/console": "7.2.*", "symfony/console": "7.2.*",
"symfony/dotenv": "7.2.*", "symfony/dotenv": "7.2.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "7.2.*",
"symfony/framework-bundle": "7.2.*", "symfony/framework-bundle": "7.2.*",
"symfony/messenger": "7.2.*",
"symfony/runtime": "7.2.*", "symfony/runtime": "7.2.*",
"symfony/security-bundle": "7.2.*", "symfony/security-bundle": "7.2.*",
"symfony/stimulus-bundle": "^2.24", "symfony/stimulus-bundle": "^2.24",

1778
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,4 +11,6 @@ return [
OneToMany\RichBundle\RichBundle::class => ['all' => true], OneToMany\RichBundle\RichBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
]; ];

11
config/packages/csrf.yaml Normal file
View File

@@ -0,0 +1,11 @@
# Enable stateless CSRF protection for forms and logins/logouts
framework:
form:
csrf_protection:
token_id: submit
csrf_protection:
stateless_token_ids:
- submit
- authenticate
- logout

View File

@@ -0,0 +1,60 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true
orm:
auto_generate_proxy_classes: true
enable_lazy_ghost_objects: true
report_fields_where_declared: true
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
# App:
# type: attribute
# is_bundle: false
# dir: '%kernel.project_dir%/src/Entity'
# prefix: 'App\Entity'
# alias: App
Download:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Download/Framework/Entity'
prefix: 'App\Download\Framework\Entity'
alias: Download
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
doctrine.system_cache_pool:
adapter: cache.system

View File

@@ -0,0 +1,6 @@
doctrine_migrations:
migrations_paths:
# namespace is arbitrary but should be different from App\Migrations
# as migrations classes should NOT be autoloaded
'DoctrineMigrations': '%kernel.project_dir%/migrations'
enable_profiler: false

View File

@@ -0,0 +1,22 @@
framework:
messenger:
# Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
# Route your messages to the transports
# 'App\Message\YourMessage': async
# when@test:
# framework:
# messenger:
# transports:
# # replace with your transport name here (e.g., my_transport: 'in-memory://')
# # For more Messenger testing tools, see https://github.com/zenstruck/messenger-test
# async: 'in-memory://'

View File

@@ -22,3 +22,4 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Download\Downloader\DownloaderInterface: "@App\\Download\\Downloader\\ProcessDownloader"

0
migrations/.gitignore vendored Executable file
View File

View File

@@ -0,0 +1,31 @@
<?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 Version20241211055503 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('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0 (queue_name), INDEX IDX_75EA56E0E3BD61CE (available_at), INDEX IDX_75EA56E016BA31DB (delivered_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE messenger_messages');
}
}

View File

@@ -0,0 +1,43 @@
<?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 Version20241218024301 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('CREATE TABLE episode (id INT AUTO_INCREMENT NOT NULL, series_id_id INT DEFAULT NULL, imdb_id VARCHAR(255) DEFAULT NULL, tvdb_id VARCHAR(255) DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, year VARCHAR(5) DEFAULT NULL, poster VARCHAR(500) DEFAULT NULL, season VARCHAR(255) DEFAULT NULL, episode VARCHAR(255) DEFAULT NULL, episode_code VARCHAR(255) DEFAULT NULL, download_directory VARCHAR(500) DEFAULT NULL, INDEX IDX_DDAA1CDAACB7A4A (series_id_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE series (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(255) NOT NULL, tvdb_id VARCHAR(255) DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, year VARCHAR(5) DEFAULT NULL, poster VARCHAR(500) DEFAULT NULL, directory VARCHAR(255) DEFAULT NULL, number_seasons INT DEFAULT NULL, number_episodes INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE episode ADD CONSTRAINT FK_DDAA1CDAACB7A4A FOREIGN KEY (series_id_id) REFERENCES series (id)');
$this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages');
$this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages');
$this->addSql('ALTER TABLE messenger_messages CHANGE id id INT AUTO_INCREMENT NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE episode DROP FOREIGN KEY FK_DDAA1CDAACB7A4A');
$this->addSql('DROP TABLE episode');
$this->addSql('DROP TABLE series');
$this->addSql('ALTER TABLE messenger_messages CHANGE id id BIGINT AUTO_INCREMENT NOT NULL');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20241226201901 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
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20241226205937 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('CREATE TABLE download (id INT AUTO_INCREMENT NOT NULL, imdb_id VARCHAR(20) DEFAULT NULL, title VARCHAR(255) DEFAULT NULL, url VARCHAR(1024) NOT NULL, filename VARCHAR(1024) DEFAULT NULL, status VARCHAR(255) DEFAULT NULL, progress INT DEFAULT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE download');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20241226214700 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('ALTER TABLE download ADD media_type VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE download DROP media_type');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20250202035615 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('ALTER TABLE download ADD batch_id VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE download DROP batch_id');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20250217221120 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('CREATE TABLE processed_messages (id INT AUTO_INCREMENT NOT NULL, run_id INT NOT NULL, attempt SMALLINT NOT NULL, message_type VARCHAR(255) NOT NULL, description VARCHAR(255) DEFAULT NULL, dispatched_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', received_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', finished_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', wait_time BIGINT NOT NULL, handle_time BIGINT NOT NULL, memory_usage INT NOT NULL, transport VARCHAR(255) NOT NULL, tags VARCHAR(255) DEFAULT NULL, failure_type VARCHAR(255) DEFAULT NULL, failure_message LONGTEXT DEFAULT NULL, results JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE preferences (id INT AUTO_INCREMENT NOT NULL, value VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('DROP TABLE processed_messages');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Controller;
use App\Download\Action\Input\DownloadMediaInput;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
class DownloadController extends AbstractController
{
public function __construct(
private MessageBusInterface $bus,
) {}
#[Route('/download', name: 'app_download', methods: ['POST'])]
public function download(
DownloadMediaInput $input,
): Response {
$this->bus->dispatch($input->toCommand());
return $this->json(['status' => 200, 'message' => 'Added to Queue']);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DownloadMediaCommand>
*/
class DownloadMediaCommand implements CommandInterface
{
public function __construct(
public string $url,
public string $title,
public string $filename,
public string $mediaType,
public string $imdbId,
) {}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
class GetDownloadOptionsCommand implements CommandInterface
{
/** @implements CommandInterface<GetDownloadOptionsCommand> */
public function __construct(
public string $tmdbId,
public string $mediaType,
) {}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\DownloadMediaCommand;
use App\Download\Action\Result\DownloadMediaResult;
use App\Download\Framework\Repository\DownloadRepository;
use App\Download\Downloader\DownloaderInterface;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
/** @implements HandlerInterface<DownloadMediaCommand, DownloadMediaResult> */
#[AsMessageHandler]
readonly class DownloadMediaHandler implements HandlerInterface
{
public function __construct(
private DownloaderInterface $downloader,
private DownloadRepository $downloadRepository,
) {}
public function __invoke(CommandInterface $command)
{
$this->handle($command);
}
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->insert(
$command->url,
$command->title,
$command->filename,
$command->imdbId,
$command->mediaType,
""
);
try {
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
$this->downloader->download(
$command->mediaType,
$command->title,
$command->url,
$download->getId()
);
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
} catch (\Throwable $exception) {
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
}
return new DownloadMediaResult(200, "Success.");
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Download\Action\Handler;
use App\Tmdb\Tmdb;
use App\Torrentio\Client\Torrentio;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
class GetDownloadOptionsHandler implements HandlerInterface
{
public function __construct(
private readonly Tmdb $tmdb,
private readonly Torrentio $torrentio,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$media = $this->tmdb->mediaDetails($command->tmdbId, $command->mediaType);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Download\Action\Input;
use App\Download\Action\Command\DownloadMediaCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/** @implements InputInterface<DownloadMediaInput> */
class DownloadMediaInput implements InputInterface
{
public function __construct(
#[SourceRequest('url')]
public string $url,
#[SourceRequest('title')]
public string $title,
#[SourceRequest('filename')]
public string $filename,
#[SourceRequest('mediaType')]
public string $mediaType,
#[SourceRequest('imdbId')]
public string $imdbId,
) {}
public function toCommand(): CommandInterface
{
return new DownloadMediaCommand(
$this->url,
$this->title,
$this->filename,
$this->mediaType,
$this->imdbId,
);
}
}

View File

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

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/** @implements ResultInterface<DownloadMediaResult> */
class DownloadMediaResult implements ResultInterface
{
public function __construct(
public int $status,
public string $message,
) {}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Download\Action\Result;
use App\Tmdb\TmdbResult;
class GetDownloadOptionsResult
{
public function __construct(
public TmdbResult $media,
) {}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Download\Downloader;
use App\Message\DownloadMessage;
use App\Message\DownloadMovieMessage;
use App\Message\DownloadTvShowMessage;
interface DownloaderInterface
{
/**
* @param string $baseDir
* @param string $title
* @param string $url
* @return void
* Downloads the requested file.
*/
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void;
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Download\Downloader;
use App\Download\Framework\Entity\Download;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;
class ProcessDownloader implements DownloaderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
/**
* @inheritDoc
*/
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
{
/** @var Download $downloadEntity */
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
$downloadEntity->setProgress(0);
$this->entityManager->flush();
$process = new Process([
'/bin/sh',
'/var/www/bash/app/wget_download.sh',
$baseDir,
$title,
$url
]);
$process->setTimeout(1800); // 30 min
$process->setIdleTimeout(600); // 10 min
$process->start();
try {
$progress = 0;
$this->entityManager->flush();
$process->wait(function ($type, $buffer) use ($progress, $downloadEntity): void {
if (Process::ERR === $type) {
$pregMatchOutput = [];
preg_match('/[\d]+%/', $buffer, $pregMatchOutput);
if (!empty($pregMatchOutput)) {
if ($pregMatchOutput[0] !== $progress) {
$progress = (int) $pregMatchOutput[0];
$downloadEntity->setProgress($progress);
$this->entityManager->flush();
}
}
}
fwrite(STDOUT, $buffer);
});
$downloadEntity->setProgress(100);
} catch (ProcessFailedException $exception) {
$downloadEntity->setStatus('Failed');
}
$this->entityManager->flush();
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Download\Downloader;
use App\Message\DownloadMessage;
use App\Message\DownloadMovieMessage;
use App\Message\DownloadTvShowMessage;
class WgetDownloader implements DownloaderInterface
{
/**
* @inheritDoc
* SSHs into the NAS and performs the download.
* This way retains the fast DL speed on the NAS.
*/
public function download(string $baseDir, string $title, string $url, ?int $downloadId): void
{
// SSHs into the NAS, cds into movies dir, makes new dir based on filename, cds into that dir, downloads movie
system(sprintf(
'sh /var/www/bash/app/wget_download.sh "%s" "%s" "%s"',
$baseDir,
$title,
$url
));
}
}

View File

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Download\Framework\Entity;
use App\Repository\DownloadRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
#[Broadcast]
class Download
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 20, nullable: true)]
private ?string $imdbId = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $mediaType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(length: 1024)]
private ?string $url = null;
#[ORM\Column(length: 1024, nullable: true)]
private ?string $filename = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $status = null;
#[ORM\Column(nullable: true)]
private ?int $progress = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $batchId = null;
public function getId(): ?int
{
return $this->id;
}
public function setId(int $id): static
{
$this->id = $id;
return $this;
}
public function getImdbId(): ?string
{
return $this->imdbId;
}
public function setImdbId(?string $imdbId): static
{
$this->imdbId = $imdbId;
return $this;
}
public function getMediaType(): ?string
{
return $this->mediaType;
}
public function setMediaType(?string $mediaType): static
{
$this->mediaType = $mediaType;
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(string $url): static
{
$this->url = $url;
return $this;
}
public function getFilename(): ?string
{
return $this->filename;
}
public function setFilename(?string $filename): static
{
$this->filename = $filename;
return $this;
}
public function getStatus(): ?string
{
return $this->status;
}
public function setStatus(?string $status): static
{
$this->status = $status;
return $this;
}
public function getProgress(): ?int
{
return $this->progress;
}
public function setProgress(?int $progress): static
{
$this->progress = $progress;
return $this;
}
public function getBatchId(): ?string
{
return $this->batchId;
}
public function setBatchId(?string $batchId): static
{
$this->batchId = $batchId;
return $this;
}
}

View File

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Download\Framework\Repository;
use App\Download\Framework\Entity\Download;
use App\ValueObject\DownloadRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Knp\Component\Pager\Paginator;
use Knp\Component\Pager\PaginatorInterface;
/**
* @extends ServiceEntityRepository<Download>
*/
class DownloadRepository extends ServiceEntityRepository
{
private ManagerRegistry $managerRegistry;
public function __construct(ManagerRegistry $registry, ManagerRegistry $managerRegistry)
{
parent::__construct($registry, Download::class);
$this->managerRegistry = $managerRegistry;
}
public function getCompletePaginated(int $pageNumber = 1, int $perPage = 10)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->orderBy('d.id', 'DESC')
->setParameter('statuses', ['Complete'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function getActivePaginated(int $pageNumber = 1, int $perPage = 10)
{
$firstResult = ($pageNumber - 1) * $perPage;
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->setParameter('statuses', ['New', 'In Progress'])
->setFirstResult($firstResult)
->setMaxResults($perPage)
->getQuery();
return new \Doctrine\ORM\Tools\Pagination\Paginator($query);
}
public function insert(
string $url,
string $title,
string $filename,
string $imdbId,
string $mediaType,
string $batchId,
string $status = 'New'
): Download {
$download = (new Download())
->setUrl($url)
->setTitle($title)
->setFilename($filename)
->setImdbId($imdbId)
->setMediaType($mediaType)
->setBatchId($batchId)
->setStatus($status);
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function insertFromDownloadRequest(DownloadRequest $request): Download
{
$download = (new Download())
->setUrl($request->downloadUrl)
->setTitle($request->seriesName)
->setFilename($request->filename)
->setImdbId($request->imdbCode)
->setMediaType($request->mediaType)
->setStatus('New');
$this->getEntityManager()->persist($download);
$this->getEntityManager()->flush();
return $download;
}
public function updateStatus(int $id, string $status): Download
{
$download = $this->find($id);
$download->setStatus($status);
$this->getEntityManager()->flush();
return $download;
}
public function delete(int $id)
{
$entity = $this->find($id);
$this->getEntityManager()->remove($entity);
$this->getEntityManager()->flush();
}
public function getPendingByBatchId(string $batchId): ?array
{
$query = $this->createQueryBuilder('d')
->andWhere('d.status IN (:statuses)')
->andWhere('d.batchId = :batchId')
->setParameter('statuses', ['New', 'In Progress'])
->setParameter('batchId', $batchId)
->getQuery();
return $query->getResult();
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Search\Action\Input; namespace App\Search\Action\Input;
use App\Download\Action\Command\GetDownloadOptionsCommand; use App\Download\Action\Command\DownloadMediaCommand;
use App\Search\Action\Command\GetMediaInfoCommand; use App\Search\Action\Command\GetMediaInfoCommand;
use OneToMany\RichBundle\Attribute\SourceRoute; use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface; use OneToMany\RichBundle\Contract\CommandInterface;

View File

@@ -2,6 +2,33 @@
"1tomany/rich-bundle": { "1tomany/rich-bundle": {
"version": "v1.8.3" "version": "v1.8.3"
}, },
"doctrine/doctrine-bundle": {
"version": "2.14",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "8d96c0b51591ffc26794d865ba3ee7d193438a83"
},
"files": [
"config/packages/doctrine.yaml",
"src/Entity/.gitignore",
"src/Repository/.gitignore"
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.1",
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
},
"files": [
"config/packages/doctrine_migrations.yaml",
"migrations/.gitignore"
]
},
"php-http/discovery": { "php-http/discovery": {
"version": "1.20", "version": "1.20",
"recipe": { "recipe": {
@@ -54,6 +81,18 @@
".env.dev" ".env.dev"
] ]
}, },
"symfony/form": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
},
"files": [
"config/packages/csrf.yaml"
]
},
"symfony/framework-bundle": { "symfony/framework-bundle": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {
@@ -82,6 +121,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
} }
}, },
"symfony/messenger": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.0",
"ref": "ba1ac4e919baba5644d31b57a3284d6ba12d52ee"
},
"files": [
"config/packages/messenger.yaml"
]
},
"symfony/routing": { "symfony/routing": {
"version": "7.2", "version": "7.2",
"recipe": { "recipe": {