wip: pauses downloads
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
FROM dunglas/frankenphp:php8.4-alpine
|
FROM dunglas/frankenphp:php8.4
|
||||||
|
|
||||||
ENV SERVER_NAME=":80"
|
ENV SERVER_NAME=":80"
|
||||||
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
|
ENV CADDY_GLOBAL_OPTIONS="auto_https off"
|
||||||
@@ -11,8 +11,8 @@ RUN install-php-extensions \
|
|||||||
zip \
|
zip \
|
||||||
opcache
|
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" ]
|
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
2
assets/bootstrap.js
vendored
@@ -1,8 +1,10 @@
|
|||||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||||
import Popover from '@stimulus-components/popover'
|
import Popover from '@stimulus-components/popover'
|
||||||
import Dialog from '@stimulus-components/dialog'
|
import Dialog from '@stimulus-components/dialog'
|
||||||
|
import Dropdown from '@stimulus-components/dropdown'
|
||||||
|
|
||||||
const app = startStimulusApp();
|
const app = startStimulusApp();
|
||||||
// register any custom, 3rd party controllers here
|
// register any custom, 3rd party controllers here
|
||||||
app.register('popover', Popover);
|
app.register('popover', Popover);
|
||||||
app.register('dialog', Dialog);
|
app.register('dialog', Dialog);
|
||||||
|
app.register('dropdown', Dropdown);
|
||||||
|
|||||||
@@ -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) {
|
deleteDownload(data) {
|
||||||
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
|
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
|
|||||||
1
assets/icons/icon-park-twotone/pause-one.svg
Normal file
1
assets/icons/icon-park-twotone/pause-one.svg
Normal 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 |
@@ -9,4 +9,8 @@ sleep $SLEEP_TIME
|
|||||||
php /app/bin/console doctrine:migrations:migrate --no-interaction
|
php /app/bin/console doctrine:migrations:migrate --no-interaction
|
||||||
php /app/bin/console db:seed
|
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
|
||||||
|
|||||||
12
docker/app/messenger-worker@.service
Normal file
12
docker/app/messenger-worker@.service
Normal 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
|
||||||
@@ -34,4 +34,10 @@ return [
|
|||||||
'@stimulus-components/dialog' => [
|
'@stimulus-components/dialog' => [
|
||||||
'version' => '1.0.1',
|
'version' => '1.0.1',
|
||||||
],
|
],
|
||||||
|
'@stimulus-components/dropdown' => [
|
||||||
|
'version' => '3.0.0',
|
||||||
|
],
|
||||||
|
'stimulus-use' => [
|
||||||
|
'version' => '0.52.2',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
15
src/Download/Action/Command/PauseDownloadCommand.php
Normal file
15
src/Download/Action/Command/PauseDownloadCommand.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -47,7 +47,9 @@ readonly class DownloadMediaHandler implements HandlerInterface
|
|||||||
$download->getId()
|
$download->getId()
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
|
if ($download->getStatus() !== 'Paused') {
|
||||||
|
$this->downloadRepository->updateStatus($download->getId(), 'Complete');
|
||||||
|
}
|
||||||
|
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
|
throw new UnrecoverableMessageHandlingException($exception->getMessage(), 500);
|
||||||
|
|||||||
39
src/Download/Action/Handler/PauseDownloadHandler.php
Normal file
39
src/Download/Action/Handler/PauseDownloadHandler.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/Download/Action/Input/PauseDownloadInput.php
Normal file
24
src/Download/Action/Input/PauseDownloadInput.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 PauseDownloadInput implements InputInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[SourceRoute('downloadId')]
|
||||||
|
public int $downloadId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function toCommand(): CommandInterface
|
||||||
|
{
|
||||||
|
return new PauseDownloadCommand(
|
||||||
|
$this->downloadId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Download/Action/Result/PauseDownloadResult.php
Normal file
16
src/Download/Action/Result/PauseDownloadResult.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<PauseDownloadResult> */
|
||||||
|
class PauseDownloadResult implements ResultInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $status,
|
||||||
|
public string $message,
|
||||||
|
public Download $download,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ use App\Monitor\Service\MediaFiles;
|
|||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||||
use Symfony\Component\Process\Process;
|
use Symfony\Component\Process\Process;
|
||||||
|
use Symfony\Contracts\Cache\CacheInterface;
|
||||||
|
|
||||||
class ProcessDownloader implements DownloaderInterface
|
class ProcessDownloader implements DownloaderInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private MediaFiles $mediaFiles,
|
private MediaFiles $mediaFiles,
|
||||||
|
private CacheInterface $cache,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +32,7 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
|
|
||||||
$process = (new Process([
|
$process = (new Process([
|
||||||
'wget',
|
'wget',
|
||||||
$url
|
$url,
|
||||||
]))->setWorkingDirectory($path);
|
]))->setWorkingDirectory($path);
|
||||||
|
|
||||||
$process->setTimeout(1800); // 30 min
|
$process->setTimeout(1800); // 30 min
|
||||||
@@ -41,7 +43,18 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
try {
|
try {
|
||||||
$progress = 0;
|
$progress = 0;
|
||||||
$this->entityManager->flush();
|
$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) {
|
if (Process::ERR === $type) {
|
||||||
$pregMatchOutput = [];
|
$pregMatchOutput = [];
|
||||||
preg_match('/[\d]+%/', $buffer, $pregMatchOutput);
|
preg_match('/[\d]+%/', $buffer, $pregMatchOutput);
|
||||||
@@ -56,7 +69,9 @@ class ProcessDownloader implements DownloaderInterface
|
|||||||
}
|
}
|
||||||
fwrite(STDOUT, $buffer);
|
fwrite(STDOUT, $buffer);
|
||||||
});
|
});
|
||||||
$downloadEntity->setProgress(100);
|
if ($downloadEntity->getStatus() !== 'Paused') {
|
||||||
|
$downloadEntity->setProgress(100);
|
||||||
|
}
|
||||||
} catch (ProcessFailedException $exception) {
|
} catch (ProcessFailedException $exception) {
|
||||||
$downloadEntity->setStatus('Failed');
|
$downloadEntity->setStatus('Failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Download\Framework\Controller;
|
namespace App\Download\Framework\Controller;
|
||||||
|
|
||||||
use App\Download\Action\Handler\DeleteDownloadHandler;
|
use App\Download\Action\Handler\DeleteDownloadHandler;
|
||||||
|
use App\Download\Action\Handler\PauseDownloadHandler;
|
||||||
use App\Download\Action\Input\DeleteDownloadInput;
|
use App\Download\Action\Input\DeleteDownloadInput;
|
||||||
use App\Download\Action\Input\DownloadMediaInput;
|
use App\Download\Action\Input\DownloadMediaInput;
|
||||||
|
use App\Download\Action\Input\PauseDownloadInput;
|
||||||
use App\Download\Framework\Repository\DownloadRepository;
|
use App\Download\Framework\Repository\DownloadRepository;
|
||||||
use App\Util\Broadcaster;
|
use App\Util\Broadcaster;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
@@ -65,4 +67,18 @@ class ApiController extends AbstractController
|
|||||||
|
|
||||||
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
|
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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class DownloadRepository extends ServiceEntityRepository
|
|||||||
->andWhere('d.user = :user')
|
->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)')
|
->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')
|
->orderBy('d.id', 'ASC')
|
||||||
->setParameter('statuses', ['New', 'In Progress'])
|
->setParameter('statuses', ['New', 'In Progress', 'Paused'])
|
||||||
->setParameter('user', $user)
|
->setParameter('user', $user)
|
||||||
->setParameter('term', '%' . $term . '%')
|
->setParameter('term', '%' . $term . '%')
|
||||||
->getQuery();
|
->getQuery();
|
||||||
|
|||||||
@@ -32,8 +32,13 @@
|
|||||||
<twig:StatusBadge color="green" status="Complete" />
|
<twig:StatusBadge color="green" status="Complete" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 flex flex-row align-middle justify-center">
|
<td class="px-6 py-4 flex flex-row items-center">
|
||||||
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', width: '18px', class: 'rounded-full align-middle text-red-600' }) %}
|
|
||||||
|
<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>
|
<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>?
|
Are you sure you want to delete <span class="font-bold">{{ download.filename }}</span>?
|
||||||
</twig:Modal>
|
</twig:Modal>
|
||||||
|
|||||||
@@ -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">
|
<dialog data-dialog-target="dialog" class="py-3 px-4 w-[30rem] rounded-md">
|
||||||
<h2 class="mb-4 text-2xl font-bold">{{ heading }}</h2>
|
<h2 class="mb-4 text-2xl font-bold">{{ heading }}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,48 @@
|
|||||||
{{ results.media.title }} - {{ results.media.year }}
|
{{ results.media.title }} - {{ results.media.year }}
|
||||||
</h3>
|
</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', {
|
<div {{ stimulus_controller('monitor_button', {
|
||||||
tmdbId: results.media.tmdbId,
|
tmdbId: results.media.tmdbId,
|
||||||
|
|||||||
Reference in New Issue
Block a user