feat: pauses & resumes downloads
This commit is contained in:
@@ -30,7 +30,13 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
pauseDownload(data) {
|
||||
fetch(`/api/download/${data.params.id}`, {method: 'PUT'})
|
||||
fetch(`/api/download/${data.params.id}/pause`, {method: 'PATCH'})
|
||||
.then(res => res.json())
|
||||
.then(json => console.debug(json));
|
||||
}
|
||||
|
||||
resumeDownload(data) {
|
||||
fetch(`/api/download/${data.params.id}/resume`, {method: 'PATCH'})
|
||||
.then(res => res.json())
|
||||
.then(json => console.debug(json));
|
||||
}
|
||||
|
||||
1
assets/icons/icon-park-twotone/play.svg
Normal file
1
assets/icons/icon-park-twotone/play.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="https://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><mask id="ipTPlay0"><g fill="#555" stroke="#fff" stroke-linejoin="round" stroke-width="4"><path d="M24 44c11.046 0 20-8.954 20-20S35.046 4 24 4S4 12.954 4 24s8.954 20 20 20Z"/><path d="M20 24v-6.928l6 3.464L32 24l-6 3.464l-6 3.464z"/></g></mask></defs><path fill="currentColor" d="M0 0h48v48H0z" mask="url(#ipTPlay0)"/></svg>
|
||||
|
After Width: | Height: | Size: 392 B |
15
src/Download/Action/Command/ResumeDownloadCommand.php
Normal file
15
src/Download/Action/Command/ResumeDownloadCommand.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Command;
|
||||
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
|
||||
/**
|
||||
* @implements CommandInterface<ResumeDownloadCommand>
|
||||
*/
|
||||
class ResumeDownloadCommand implements CommandInterface
|
||||
{
|
||||
public function __construct(
|
||||
public int $downloadId,
|
||||
) {}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
||||
}
|
||||
|
||||
try {
|
||||
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
|
||||
if ($download->getStatus() !== 'Paused') {
|
||||
$this->downloadRepository->updateStatus($download->getId(), 'In Progress');
|
||||
}
|
||||
|
||||
$this->downloader->download(
|
||||
$command->mediaType,
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -15,11 +14,8 @@ 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,
|
||||
) {}
|
||||
|
||||
@@ -32,8 +28,6 @@ readonly class PauseDownloadHandler implements HandlerInterface
|
||||
return true;
|
||||
});
|
||||
|
||||
$download->setFilename($download->getFilename() . self::PAUSED_EXTENSION);
|
||||
|
||||
return new PauseDownloadResult(200, 'Success', $download);
|
||||
}
|
||||
}
|
||||
|
||||
40
src/Download/Action/Handler/ResumeDownloadHandler.php
Normal file
40
src/Download/Action/Handler/ResumeDownloadHandler.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Handler;
|
||||
|
||||
use App\Download\Action\Command\DownloadMediaCommand;
|
||||
use App\Download\Action\Command\ResumeDownloadCommand;
|
||||
use App\Download\Action\Result\ResumeDownloadResult;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use OneToMany\RichBundle\Contract\CommandInterface;
|
||||
use OneToMany\RichBundle\Contract\HandlerInterface;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/** @implements HandlerInterface<ResumeDownloadCommand, ResumeDownloadResult> */
|
||||
readonly class ResumeDownloadHandler implements HandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private DownloadRepository $downloadRepository,
|
||||
private MessageBusInterface $messageBus,
|
||||
) {}
|
||||
|
||||
public function handle(CommandInterface $command): ResultInterface
|
||||
{
|
||||
/** @var Download $download */
|
||||
$download = $this->downloadRepository->find($command->downloadId);
|
||||
|
||||
$this->messageBus->dispatch(new DownloadMediaCommand(
|
||||
$download->getUrl(),
|
||||
$download->getTitle(),
|
||||
$download->getFilename(),
|
||||
$download->getMediaType(),
|
||||
$download->getImdbId(),
|
||||
$download->getUser()->getId(),
|
||||
$download->getId()
|
||||
));
|
||||
|
||||
return new ResumeDownloadResult(200, 'Success', $download);
|
||||
}
|
||||
}
|
||||
24
src/Download/Action/Input/ResumeDownloadInput.php
Normal file
24
src/Download/Action/Input/ResumeDownloadInput.php
Normal 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 ResumeDownloadInput implements InputInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[SourceRoute('downloadId')]
|
||||
public int $downloadId,
|
||||
) {}
|
||||
|
||||
public function toCommand(): CommandInterface
|
||||
{
|
||||
return new PauseDownloadCommand(
|
||||
$this->downloadId,
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/Download/Action/Result/ResumeDownloadResult.php
Normal file
16
src/Download/Action/Result/ResumeDownloadResult.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Download\Action\Result;
|
||||
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use OneToMany\RichBundle\Contract\ResultInterface;
|
||||
|
||||
/** @implements ResultInterface<ResumeDownloadResult> */
|
||||
class ResumeDownloadResult implements ResultInterface
|
||||
{
|
||||
public function __construct(
|
||||
public int $status,
|
||||
public string $message,
|
||||
public Download $download,
|
||||
) {}
|
||||
}
|
||||
@@ -5,12 +5,17 @@ namespace App\Download\Downloader;
|
||||
use App\Download\Framework\Entity\Download;
|
||||
use App\Monitor\Service\MediaFiles;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Cache\Adapter\RedisAdapter;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
|
||||
class ProcessDownloader implements DownloaderInterface
|
||||
{
|
||||
/**
|
||||
* @var RedisAdapter $cache
|
||||
*/
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private MediaFiles $mediaFiles,
|
||||
@@ -24,16 +29,23 @@ class ProcessDownloader implements DownloaderInterface
|
||||
{
|
||||
/** @var Download $downloadEntity */
|
||||
$downloadEntity = $this->entityManager->getRepository(Download::class)->find($downloadId);
|
||||
$downloadEntity->setProgress(0);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$downloadPreferences = $downloadEntity->getUser()->getDownloadPreferences();
|
||||
$path = $this->getDownloadPath($mediaType, $title, $downloadPreferences);
|
||||
|
||||
$process = (new Process([
|
||||
'wget',
|
||||
$url,
|
||||
]))->setWorkingDirectory($path);
|
||||
$processArgs = ['wget', $url];
|
||||
|
||||
if ($downloadEntity->getStatus() === 'Paused') {
|
||||
$downloadEntity->setStatus('In Progress');
|
||||
$processArgs = ['wget', '-c', $url];
|
||||
} else {
|
||||
$downloadEntity->setProgress(0);
|
||||
}
|
||||
|
||||
fwrite(STDOUT, implode(" ", $processArgs));
|
||||
|
||||
$process = (new Process($processArgs))->setWorkingDirectory($path);
|
||||
|
||||
$process->setTimeout(1800); // 30 min
|
||||
$process->setIdleTimeout(600); // 10 min
|
||||
@@ -48,10 +60,10 @@ class ProcessDownloader implements DownloaderInterface
|
||||
// The PauseDownloadHandler will set this to 'true'
|
||||
$doPause = $this->cache->getItem('download.pause.' . $downloadEntity->getId());
|
||||
|
||||
if (true === $doPause->isHit()) {
|
||||
if (true === $doPause->isHit() && true === $doPause->get()) {
|
||||
$downloadEntity->setStatus('Paused');
|
||||
$this->cache->deleteItem('download.pause.' . $downloadEntity->getId());
|
||||
$this->entityManager->flush();
|
||||
$doPause->expiresAt(new \DateTimeImmutable('now'));
|
||||
$process->stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Download\Framework\Controller;
|
||||
|
||||
use App\Download\Action\Handler\DeleteDownloadHandler;
|
||||
use App\Download\Action\Handler\PauseDownloadHandler;
|
||||
use App\Download\Action\Handler\ResumeDownloadHandler;
|
||||
use App\Download\Action\Input\DeleteDownloadInput;
|
||||
use App\Download\Action\Input\DownloadMediaInput;
|
||||
use App\Download\Action\Input\PauseDownloadInput;
|
||||
use App\Download\Action\Input\ResumeDownloadInput;
|
||||
use App\Download\Framework\Repository\DownloadRepository;
|
||||
use App\Util\Broadcaster;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -68,7 +70,7 @@ class ApiController extends AbstractController
|
||||
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
|
||||
}
|
||||
|
||||
#[Route('/api/download/{downloadId}', name: 'api_download_pause', methods: ['PUT'])]
|
||||
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]
|
||||
public function pauseDownload(
|
||||
PauseDownloadInput $input,
|
||||
PauseDownloadHandler $handler,
|
||||
@@ -81,4 +83,18 @@ class ApiController extends AbstractController
|
||||
|
||||
return $this->json(['status' => 200, 'message' => 'Download Paused']);
|
||||
}
|
||||
|
||||
#[Route('/api/download/{downloadId}/resume', name: 'api_download_resume', methods: ['PATCH'])]
|
||||
public function resumeDownload(
|
||||
ResumeDownloadInput $input,
|
||||
ResumeDownloadHandler $handler,
|
||||
): Response {
|
||||
$result = $handler->handle($input->toCommand());
|
||||
$this->broadcaster->alert(
|
||||
title: 'Success',
|
||||
message: "{$result->download->getTitle()} has been Resumed.",
|
||||
);
|
||||
|
||||
return $this->json(['status' => 200, 'message' => 'Download Resumed']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,31 @@
|
||||
{% if entity.status != "Complete" %}
|
||||
<turbo-stream action="update" target="download_progress_{{ id }}">
|
||||
<template>
|
||||
<div class="text-green-700 rounded-sm text-bold text-gray-950 text-center bg-green-600 h-5"
|
||||
<div class="text-black text-center rounded-sm text-bold bg-green-300 h-5 relative z-10"
|
||||
style="width:{{ entity.progress }}%">
|
||||
<span>{{ entity.progress }}%</span>
|
||||
</div>
|
||||
<div class="absolute text-black text-center"
|
||||
style="z-index: 400;margin-top: -1.25rem; margin-left: 1.2rem">
|
||||
{{ entity.progress }}%
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
<turbo-stream action="update" target="action_buttons_{{ id }}">
|
||||
<template>
|
||||
{% if entity.status == "In Progress" and entity.progress < 100 %}
|
||||
<button id="pause_{{ entity.id }}" class="text-orange-500 mr-1 self-start" {{ stimulus_action('download_list', 'pauseDownload', 'click', {id: entity.id}) }}>
|
||||
<twig:ux:icon name="icon-park-twotone:pause-one" width="16.75px" height="16.75px" class="rounded-full" />
|
||||
</button>
|
||||
{% elseif entity.status == "Paused" %}
|
||||
<button id="resume_{{ entity.id }}" class="text-orange-500 mr-1 self-start" {{ stimulus_action('download_list', 'resumeDownload', 'click', {id: entity.id}) }}>
|
||||
<twig:ux:icon name="icon-park-twotone:play" width="16.75px" height="16.75px" class="rounded-full" />
|
||||
</button>
|
||||
{% endif %}
|
||||
{% 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: entity.id}) }}" show_cancel show_submit>
|
||||
Are you sure you want to delete <span class="font-bold">{{ entity.filename }}</span>?
|
||||
</twig:Modal>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
{% else %}
|
||||
@@ -27,6 +48,9 @@
|
||||
<turbo-stream action="remove" target="ad_download_{{ id }}">
|
||||
</turbo-stream>
|
||||
|
||||
<turbo-stream action="remove" target="action_buttons_{{ id }}">
|
||||
</turbo-stream>
|
||||
|
||||
<turbo-stream action="prepend" target="alert_list">
|
||||
<template>
|
||||
<twig:Alert title="Finished downloading" message="{{ entity.title }}" alert_id="{{ entity.id }}" data-controller="alert" />
|
||||
|
||||
@@ -32,16 +32,20 @@
|
||||
<twig:StatusBadge color="green" status="Complete" />
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 flex flex-row items-center">
|
||||
<td id="action_buttons_{{ download.id }}" class="px-6 py-4 flex flex-row items-center">
|
||||
{% if download.status == 'In Progress' and download.progress < 100 %}
|
||||
<button id="pause_{{ download.id }}" 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="16.75px" height="16.75px" class="rounded-full" />
|
||||
</button>
|
||||
{% elseif download.status == 'Paused' %}
|
||||
<button id="resume_{{ download.id }}" class="text-orange-500 mr-1 self-start" {{ stimulus_action('download_list', 'resumeDownload', 'click', {id: download.id}) }}>
|
||||
<twig:ux:icon name="icon-park-twotone:play" width="16.75px" height="16.75px" class="rounded-full" />
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<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' }) %}
|
||||
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', 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>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
Reference in New Issue
Block a user