diff --git a/Dockerfile b/Dockerfile index 9188c10..f5fe56e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM dunglas/frankenphp:php8.4-alpine +FROM dunglas/frankenphp:php8.4 ENV SERVER_NAME=":80" ENV CADDY_GLOBAL_OPTIONS="auto_https off" @@ -11,8 +11,8 @@ RUN install-php-extensions \ zip \ opcache -RUN apk add --no-cache wget +RUN apt update && apt install -y wget HEALTHCHECK --interval=3s --timeout=3s --retries=10 CMD [ "php", "/app/bin/console", "startup:status" ] -COPY docker/app/Caddyfile /etc/frankenphp/Caddyfile +COPY --chmod=0755 docker/app/Caddyfile /etc/frankenphp/Caddyfile diff --git a/assets/bootstrap.js b/assets/bootstrap.js index 4d08a22..9b07d31 100644 --- a/assets/bootstrap.js +++ b/assets/bootstrap.js @@ -1,8 +1,10 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; import Popover from '@stimulus-components/popover' import Dialog from '@stimulus-components/dialog' +import Dropdown from '@stimulus-components/dropdown' const app = startStimulusApp(); // register any custom, 3rd party controllers here app.register('popover', Popover); app.register('dialog', Dialog); +app.register('dropdown', Dropdown); diff --git a/assets/controllers/download_list_controller.js b/assets/controllers/download_list_controller.js index 09889e0..8e051fc 100644 --- a/assets/controllers/download_list_controller.js +++ b/assets/controllers/download_list_controller.js @@ -29,6 +29,12 @@ export default class extends Controller { } } + pauseDownload(data) { + fetch(`/api/download/${data.params.id}`, {method: 'PUT'}) + .then(res => res.json()) + .then(json => console.debug(json)); + } + deleteDownload(data) { fetch(`/api/download/${data.params.id}`, {method: 'DELETE'}) .then(res => res.json()) diff --git a/assets/icons/icon-park-twotone/pause-one.svg b/assets/icons/icon-park-twotone/pause-one.svg new file mode 100644 index 0000000..724fbf3 --- /dev/null +++ b/assets/icons/icon-park-twotone/pause-one.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh index 31c36e4..cfe7bfe 100644 --- a/docker/app/entrypoint.sh +++ b/docker/app/entrypoint.sh @@ -9,4 +9,8 @@ sleep $SLEEP_TIME php /app/bin/console doctrine:migrations:migrate --no-interaction php /app/bin/console db:seed -exec docker-php-entrypoint "$@" +# Start the media cache warming services +systemctl --user enable messenger-worker.service +systemctl --user start messenger-worker.service + +frankenphp php-server /etc/frankenphp/Caddyfile diff --git a/docker/app/messenger-worker@.service b/docker/app/messenger-worker@.service new file mode 100644 index 0000000..dd4d4f4 --- /dev/null +++ b/docker/app/messenger-worker@.service @@ -0,0 +1,12 @@ +[Unit] +Description=Torsearch media cache warming services + +[Service] +ExecStart=php /app/bin/console messenger:consume media_cache --time-limit=3600 +# for Redis, set a custom consumer name for each instance +Environment="MESSENGER_CONSUMER_NAME=symfony-%n-%i" +Restart=always +RestartSec=30 + +[Install] +WantedBy=default.target diff --git a/importmap.php b/importmap.php index a295579..3db9e68 100644 --- a/importmap.php +++ b/importmap.php @@ -34,4 +34,10 @@ return [ '@stimulus-components/dialog' => [ 'version' => '1.0.1', ], + '@stimulus-components/dropdown' => [ + 'version' => '3.0.0', + ], + 'stimulus-use' => [ + 'version' => '0.52.2', + ], ]; diff --git a/src/Download/Action/Command/PauseDownloadCommand.php b/src/Download/Action/Command/PauseDownloadCommand.php new file mode 100644 index 0000000..050939b --- /dev/null +++ b/src/Download/Action/Command/PauseDownloadCommand.php @@ -0,0 +1,15 @@ + + */ +class PauseDownloadCommand implements CommandInterface +{ + public function __construct( + public int $downloadId, + ) {} +} \ No newline at end of file diff --git a/src/Download/Action/Handler/DownloadMediaHandler.php b/src/Download/Action/Handler/DownloadMediaHandler.php index 6207f51..4adcff9 100644 --- a/src/Download/Action/Handler/DownloadMediaHandler.php +++ b/src/Download/Action/Handler/DownloadMediaHandler.php @@ -47,7 +47,9 @@ readonly class DownloadMediaHandler implements HandlerInterface $download->getId() ); - $this->downloadRepository->updateStatus($download->getId(), 'Complete'); + if ($download->getStatus() !== 'Paused') { + $this->downloadRepository->updateStatus($download->getId(), 'Complete'); + } } catch (\Throwable $exception) { throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500); diff --git a/src/Download/Action/Handler/PauseDownloadHandler.php b/src/Download/Action/Handler/PauseDownloadHandler.php new file mode 100644 index 0000000..986b070 --- /dev/null +++ b/src/Download/Action/Handler/PauseDownloadHandler.php @@ -0,0 +1,39 @@ + */ +readonly class PauseDownloadHandler implements HandlerInterface +{ + const PAUSED_EXTENSION = '.paused'; + + public function __construct( + private DownloadRepository $downloadRepository, + private MediaFiles $mediaFiles, + private CacheInterface $cache, + ) {} + + public function handle(CommandInterface $command): ResultInterface + { + /** @var Download $download */ + $download = $this->downloadRepository->find($command->downloadId); + + $this->cache->get('download.pause.' . $download->getId(), function () { + return true; + }); + + $download->setFilename($download->getFilename() . self::PAUSED_EXTENSION); + + return new PauseDownloadResult(200, 'Success', $download); + } +} diff --git a/src/Download/Action/Input/PauseDownloadInput.php b/src/Download/Action/Input/PauseDownloadInput.php new file mode 100644 index 0000000..a6c5b5c --- /dev/null +++ b/src/Download/Action/Input/PauseDownloadInput.php @@ -0,0 +1,24 @@ + */ +class PauseDownloadInput implements InputInterface +{ + public function __construct( + #[SourceRoute('downloadId')] + public int $downloadId, + ) {} + + public function toCommand(): CommandInterface + { + return new PauseDownloadCommand( + $this->downloadId, + ); + } +} \ No newline at end of file diff --git a/src/Download/Action/Result/PauseDownloadResult.php b/src/Download/Action/Result/PauseDownloadResult.php new file mode 100644 index 0000000..08066f2 --- /dev/null +++ b/src/Download/Action/Result/PauseDownloadResult.php @@ -0,0 +1,16 @@ + */ +class PauseDownloadResult implements ResultInterface +{ + public function __construct( + public int $status, + public string $message, + public Download $download, + ) {} +} diff --git a/src/Download/Downloader/ProcessDownloader.php b/src/Download/Downloader/ProcessDownloader.php index b878a34..260d765 100644 --- a/src/Download/Downloader/ProcessDownloader.php +++ b/src/Download/Downloader/ProcessDownloader.php @@ -7,12 +7,14 @@ use App\Monitor\Service\MediaFiles; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; +use Symfony\Contracts\Cache\CacheInterface; class ProcessDownloader implements DownloaderInterface { public function __construct( private EntityManagerInterface $entityManager, private MediaFiles $mediaFiles, + private CacheInterface $cache, ) {} /** @@ -30,7 +32,7 @@ class ProcessDownloader implements DownloaderInterface $process = (new Process([ 'wget', - $url + $url, ]))->setWorkingDirectory($path); $process->setTimeout(1800); // 30 min @@ -41,7 +43,18 @@ class ProcessDownloader implements DownloaderInterface try { $progress = 0; $this->entityManager->flush(); - $process->wait(function ($type, $buffer) use ($progress, $downloadEntity): void { + + $process->wait(function ($type, $buffer) use ($progress, $downloadEntity, $process): void { + // The PauseDownloadHandler will set this to 'true' + $doPause = $this->cache->getItem('download.pause.' . $downloadEntity->getId()); + + if (true === $doPause->isHit()) { + $downloadEntity->setStatus('Paused'); + $this->entityManager->flush(); + $doPause->expiresAt(new \DateTimeImmutable('now')); + $process->stop(); + } + if (Process::ERR === $type) { $pregMatchOutput = []; preg_match('/[\d]+%/', $buffer, $pregMatchOutput); @@ -56,7 +69,9 @@ class ProcessDownloader implements DownloaderInterface } fwrite(STDOUT, $buffer); }); - $downloadEntity->setProgress(100); + if ($downloadEntity->getStatus() !== 'Paused') { + $downloadEntity->setProgress(100); + } } catch (ProcessFailedException $exception) { $downloadEntity->setStatus('Failed'); } diff --git a/src/Download/Framework/Controller/ApiController.php b/src/Download/Framework/Controller/ApiController.php index b32edca..9e54742 100644 --- a/src/Download/Framework/Controller/ApiController.php +++ b/src/Download/Framework/Controller/ApiController.php @@ -3,8 +3,10 @@ namespace App\Download\Framework\Controller; use App\Download\Action\Handler\DeleteDownloadHandler; +use App\Download\Action\Handler\PauseDownloadHandler; use App\Download\Action\Input\DeleteDownloadInput; use App\Download\Action\Input\DownloadMediaInput; +use App\Download\Action\Input\PauseDownloadInput; use App\Download\Framework\Repository\DownloadRepository; use App\Util\Broadcaster; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -65,4 +67,18 @@ class ApiController extends AbstractController return $this->json(['status' => 200, 'message' => 'Download Deleted']); } + + #[Route('/api/download/{downloadId}', name: 'api_download_pause', methods: ['PUT'])] + public function pauseDownload( + PauseDownloadInput $input, + PauseDownloadHandler $handler, + ): Response { + $result = $handler->handle($input->toCommand()); + $this->broadcaster->alert( + title: 'Success', + message: "{$result->download->getTitle()} has been Paused.", + ); + + return $this->json(['status' => 200, 'message' => 'Download Paused']); + } } diff --git a/src/Download/Framework/Repository/DownloadRepository.php b/src/Download/Framework/Repository/DownloadRepository.php index eb02681..de29746 100644 --- a/src/Download/Framework/Repository/DownloadRepository.php +++ b/src/Download/Framework/Repository/DownloadRepository.php @@ -47,7 +47,7 @@ class DownloadRepository extends ServiceEntityRepository ->andWhere('d.user = :user') ->andWhere('(d.title LIKE :term OR d.filename LIKE :term OR d.imdbId LIKE :term OR d.status LIKE :term OR d.mediaType LIKE :term)') ->orderBy('d.id', 'ASC') - ->setParameter('statuses', ['New', 'In Progress']) + ->setParameter('statuses', ['New', 'In Progress', 'Paused']) ->setParameter('user', $user) ->setParameter('term', '%' . $term . '%') ->getQuery(); diff --git a/templates/components/DownloadListRow.html.twig b/templates/components/DownloadListRow.html.twig index ee8a647..694bd75 100644 --- a/templates/components/DownloadListRow.html.twig +++ b/templates/components/DownloadListRow.html.twig @@ -32,8 +32,13 @@ {% endif %} - - {% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '18px', class: 'rounded-full align-middle text-red-600' }) %} + + + + + {% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '17.5px', class: 'rounded-full align-middle text-red-600' }) %} Are you sure you want to delete {{ download.filename }}? diff --git a/templates/components/Modal.html.twig b/templates/components/Modal.html.twig index 2847967..91e6ea9 100644 --- a/templates/components/Modal.html.twig +++ b/templates/components/Modal.html.twig @@ -1,4 +1,4 @@ - +

{{ heading }}

diff --git a/templates/search/result.html.twig b/templates/search/result.html.twig index 199de6c..e169320 100644 --- a/templates/search/result.html.twig +++ b/templates/search/result.html.twig @@ -22,6 +22,48 @@ {{ results.media.title }} - {{ results.media.year }} +{#
#} +{# #} + +{#
#} +{# #} +