wip: pauses downloads

This commit is contained in:
2025-06-09 11:26:17 -05:00
parent 51c2a1c577
commit a7bedae3db
18 changed files with 217 additions and 12 deletions

View File

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

2
assets/bootstrap.js vendored
View File

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

View File

@@ -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())

View File

@@ -0,0 +1 @@
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPauseOne0"><g fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path fill="#555" d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path stroke-linecap="round" d="M19 18v12m10-12v12"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPauseOne0)"/></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

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

View File

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

View File

@@ -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',
],
];

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Download\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<PauseDownloadCommand>
*/
class PauseDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
) {}
}

View File

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

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Download\Action\Handler;
use App\Download\Action\Command\PauseDownloadCommand;
use App\Download\Action\Result\PauseDownloadResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Monitor\Service\MediaFiles;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Contracts\Cache\CacheInterface;
/** @implements HandlerInterface<PauseDownloadCommand, PauseDownloadResult> */
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);
}
}

View File

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

View File

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

View File

@@ -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');
}

View File

@@ -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']);
}
}

View File

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

View File

@@ -32,8 +32,13 @@
<twig:StatusBadge color="green" status="Complete" />
{% endif %}
</td>
<td class="px-6 py-4 flex flex-row align-middle justify-center">
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '18px', class: 'rounded-full align-middle text-red-600' }) %}
<td class="px-6 py-4 flex flex-row items-center">
<button class="text-orange-500 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: download.id}) }}>
<twig:ux:icon name="icon-park-twotone:pause-one" width="17px" height="16.75px" class="rounded-full" />
</button>
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '17.5px', class: 'rounded-full align-middle text-red-600' }) %}
<twig:Modal heading="But wait!" button_text="{{ delete_button }}" submit_action="{{ stimulus_action('download_list', 'deleteDownload', 'click', {id: download.id}) }}" show_cancel show_submit>
Are you sure you want to delete <span class="font-bold">{{ download.filename }}</span>?
</twig:Modal>

View File

@@ -1,4 +1,4 @@
<div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose">
<div{{ attributes }} data-controller="dialog" data-action="click->dialog#backdropClose" class="flex flex-row items-center">
<dialog data-dialog-target="dialog" class="py-3 px-4 w-[30rem] rounded-md">
<h2 class="mb-4 text-2xl font-bold">{{ heading }}</h2>

View File

@@ -22,6 +22,48 @@
{{ results.media.title }} - {{ results.media.year }}
</h3>
{# <div data-controller="dropdown" class="relative"#}
{# {{ stimulus_controller('monitor_button', {#}
{# tmdbId: results.media.tmdbId,#}
{# imdbId: results.media.imdbId,#}
{# title: results.media.title,#}
{# })}}#}
{# data-monitor-button-result-filter-outlet="#filter"#}
{# >#}
{# <button type="button" data-action="dropdown#toggle click@window->dropdown#hide"#}
{# class="h-8 text-white bg-green-800 bg-opacity-60 font-medium rounded-lg text-sm#}
{# px-2 py-1.5 text-center inline-flex items-center hover:bg-green-900 border-2#}
{# border-green-500">#}
{# Monitor#}
{# <svg class="w-2.5 h-2.5 ms-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">#}
{# <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" /></svg>#}
{# </svg>#}
{# </button>#}
{# <div#}
{# data-dropdown-target="menu"#}
{# class="hidden transition transform origin-top-right absolute right-0#}
{# flex flex-col rounded-md shadow-sm w-44 bg-green-800 border-2 border-green-500 mt-1"#}
{# data-transition-enter-from="opacity-0 scale-95"#}
{# data-transition-enter-to="opacity-100 scale-100"#}
{# data-transition-leave-from="opacity-100 scale-100"#}
{# data-transition-leave-to="opacity-0 scale-95"#}
{# >#}
{# <a href="#"#}
{# data-action="dropdown#toggle"#}
{# class="backdrop-filter p-2 bg-opacity-100 hover:bg-green-950 rounded-t-md"#}
{# >#}
{# Entire Series#}
{# </a>#}
{# <a href="#"#}
{# data-action="dropdown#toggle"#}
{# class="backdrop-filter p-2 bg-opacity-100 hover:bg-green-950 rounded-b-md"#}
{# >#}
{# Season#}
{# </a>#}
{# </div>#}
{# </div>#}
<div {{ stimulus_controller('monitor_button', {
tmdbId: results.media.tmdbId,