Compare commits

..

2 Commits

19 changed files with 206 additions and 29 deletions

1
.gitignore vendored
View File

@@ -18,3 +18,4 @@ bolt.db
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
.php-cs-fixer.cache

View File

@@ -7,7 +7,7 @@ import { getComponent } from '@symfony/ux-live-component';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['download']
static targets = ['download', 'deleteFileInput']
async initialize() {
this.component = await getComponent(this.element);
@@ -42,7 +42,8 @@ export default class extends Controller {
}
deleteDownload(data) {
fetch(`/api/download/${data.params.id}`, {method: 'DELETE'})
const deleteFileInput = document.querySelector(`#delete_file_${data.params.id}`)
fetch(`/api/download/${data.params.id}?deleteFile=${deleteFileInput.checked}`, {method: 'DELETE'})
.then(res => res.json())
.then(json => console.debug(json));
}

View File

@@ -5,6 +5,7 @@ namespace App\Base\Service;
use Aimeos\Map;
use App\Download\Framework\Entity\Download;
use Nihilarr\PTN;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
@@ -21,6 +22,7 @@ class MediaFiles
private string $tvShowsPath;
private Filesystem $filesystem;
private LoggerInterface $logger;
public function __construct(
#[Autowire(param: 'media.base_path')]
@@ -33,12 +35,14 @@ class MediaFiles
string $tvShowsPath,
Filesystem $filesystem,
LoggerInterface $logger,
) {
$this->finder = new Finder();
$this->basePath = $basePath;
$this->moviesPath = $moviesPath;
$this->tvShowsPath = $tvShowsPath;
$this->filesystem = $filesystem;
$this->logger = $logger;
}
public function getPathByType(string $mediaType): string
@@ -220,4 +224,26 @@ class MediaFiles
{
$this->filesystem->chmod($filepath, $permissions);
}
/**
* @param string $filepath
* @return bool
* Returns true if file was deleted
* Returns false is file not found or was not deleted
*/
public function removeFile(string $filepath): bool
{
if (true === $this->filesystem->exists($filepath)) {
try {
$this->filesystem->remove($filepath);
return true;
} catch (\Throwable $exception) {
$this->logger->error($exception->getMessage(), ['file' => $filepath]);
return false;
}
}
$this->logger->warning('> [MediaFiles] Attempted to remove file, but it did not exist.', ['file' => $filepath]);
return false;
}
}

View File

@@ -11,5 +11,6 @@ class DeleteDownloadCommand implements CommandInterface
{
public function __construct(
public int $downloadId,
public bool $deleteFile = false,
) {}
}

View File

@@ -5,6 +5,8 @@ namespace App\Download\Action\Handler;
use App\Download\Action\Command\DeleteDownloadCommand;
use App\Download\Action\Result\DeleteDownloadResult;
use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use App\Library\Action\Handler\DeleteMediaFileHandler;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
@@ -14,13 +16,26 @@ readonly class DeleteDownloadHandler implements HandlerInterface
{
public function __construct(
private DownloadRepository $downloadRepository,
private DeleteMediaFileHandler $deleteMediaFileHandler,
) {}
public function handle(CommandInterface $command): ResultInterface
{
$download = $this->downloadRepository->find($command->downloadId);
if (true === $command->deleteFile) {
$deletedFileResult = $this->deleteMediaFileHandler->handle(new DeleteMediaFileCommand(
filename: $download->getFilename(),
downloadId: $command->downloadId
));
}
$this->downloadRepository->delete($command->downloadId);
return new DeleteDownloadResult(200, 'Success', $download);
return new DeleteDownloadResult(
status: 200,
message: 'Success',
download: $download,
deleteMediaFileResult: $deletedFileResult ?? null
);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Download\Action\Input;
use App\Download\Action\Command\DeleteDownloadCommand;
use OneToMany\RichBundle\Attribute\SourceQuery;
use OneToMany\RichBundle\Attribute\SourceRoute;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
@@ -13,12 +14,15 @@ class DeleteDownloadInput implements InputInterface
public function __construct(
#[SourceRoute('downloadId')]
public int $downloadId,
#[SourceQuery('deleteFile')]
public bool $deleteFile = false,
) {}
public function toCommand(): CommandInterface
{
return new DeleteDownloadCommand(
$this->downloadId,
$this->deleteFile,
);
}
}

View File

@@ -12,5 +12,6 @@ class DeleteDownloadResult implements ResultInterface
public int $status,
public string $message,
public Download $download,
public ?DeleteMediaFileResult $deleteMediaFileResult = null,
) {}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Download\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $message,
public string $filepath,
public bool $isDeleted,
) {}
}

View File

@@ -66,7 +66,7 @@ class ApiController extends AbstractController
message: "{$result->download->getTitle()} has been deleted.",
);
return $this->json(['status' => 200, 'message' => 'Download Deleted']);
return $this->json($result);
}
#[Route('/api/download/{downloadId}/pause', name: 'api_download_pause', methods: ['PATCH'])]

View File

@@ -7,6 +7,7 @@ use App\User\Framework\Entity\User;
use Doctrine\ORM\Mapping as ORM;
use Gedmo\Timestampable\Traits\TimestampableEntity;
use Nihilarr\PTN;
use Symfony\Component\Serializer\Attribute\Ignore;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity(repositoryClass: DownloadRepository::class)]
@@ -44,6 +45,7 @@ class Download
#[ORM\Column(length: 255, nullable: true)]
private ?string $episodeId = null;
#[Ignore]
#[ORM\ManyToOne(inversedBy: 'downloads')]
private ?User $user = null;

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Library\Action\Command;
use OneToMany\RichBundle\Contract\CommandInterface;
/**
* @implements CommandInterface<DeleteMediaFileCommand>
*/
class DeleteMediaFileCommand implements CommandInterface
{
public function __construct(
public string $filename,
public ?int $downloadId = null,
) {}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Library\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Download\Action\Result\DeleteMediaFileResult;
use App\Download\Framework\Entity\Download;
use App\Download\Framework\Repository\DownloadRepository;
use App\Library\Action\Command\DeleteMediaFileCommand;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\HandlerInterface;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements HandlerInterface<DeleteMediaFileCommand,DeleteMediaFileResult>
*/
class DeleteMediaFileHandler implements HandlerInterface
{
public function __construct(
private readonly DownloadRepository $downloadRepository,
private readonly MediaFiles $mediaFiles,
) {}
public function handle(CommandInterface $command): ResultInterface
{
/** @var Download $downloadRecord */
$downloadRecord = $this->downloadRepository->find($command->downloadId);
$filepath = $this->getFullFilepath($downloadRecord);
$result = $this->mediaFiles->removeFile($filepath);
return new DeleteMediaFileResult(
message: true === $result ? 'File removed' : 'File not removed',
filepath: $filepath,
isDeleted: $result
);
}
private function getFullFilepath(Download $download): string
{
return $this->mediaFiles->getPathByType($download->getMediaType()) . DIRECTORY_SEPARATOR . $download->getFilename();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Library\Action\Input;
use App\Library\Action\Command\DeleteMediaFileCommand;
use OneToMany\RichBundle\Attribute\SourceRequest;
use OneToMany\RichBundle\Contract\CommandInterface;
use OneToMany\RichBundle\Contract\InputInterface;
/**
* @implements InputInterface<DeleteMediaFileInput,DeleteMediaFileCommand>
*/
class DeleteMediaFileInput implements InputInterface
{
public function __construct(
#[SourceRequest('filename')]
public string $filename,
#[SourceRequest('downloadId', nullify: true)]
public ?int $downloadId = null,
) {}
public function toCommand(): CommandInterface
{
return new DeleteMediaFileCommand(
$this->filename,
$this->downloadId,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Library\Action\Result;
use OneToMany\RichBundle\Contract\ResultInterface;
/**
* @implements ResultInterface
*/
class DeleteMediaFileResult implements ResultInterface
{
public function __construct(
public string $status,
) {}
}

View File

@@ -3,6 +3,7 @@
namespace App\Torrentio\Action\Handler;
use App\Base\Service\MediaFiles;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\Tmdb;
use App\Torrentio\Action\Command\GetTvShowOptionsCommand;
use App\Torrentio\Action\Result\GetTvShowOptionsResult;
@@ -28,7 +29,7 @@ class GetTvShowOptionsHandler implements HandlerInterface
return new GetTvShowOptionsResult(
media: $media,
file: $file,
file: MediaFileDto::fromSplFileInfo($file),
season: $command->season,
episode: $command->episode,
results: $this->torrentio->fetchEpisodeResults(

View File

@@ -2,15 +2,15 @@
namespace App\Torrentio\Action\Result;
use App\Library\Dto\MediaFileDto;
use App\Tmdb\TmdbResult;
use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Component\Finder\SplFileInfo;
class GetTvShowOptionsResult implements ResultInterface
{
public function __construct(
public TmdbResult $media,
public bool|SplFileInfo $file,
public bool|MediaFileDto $file,
public string $season,
public string $episode,
public array $results

View File

@@ -13,6 +13,7 @@ use OneToMany\RichBundle\Contract\ResultInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
@@ -26,6 +27,7 @@ final class WebController extends AbstractController
private readonly Broadcaster $broadcaster,
) {}
#[Cache(expires: 3600, public: false, mustRevalidate: true)]
#[Route('/torrentio/movies/{tmdbId}/{imdbId}', name: 'app_torrentio_movies')]
public function movieOptions(GetMovieOptionsInput $input, CacheInterface $cache, Request $request): Response
{
@@ -35,9 +37,10 @@ final class WebController extends AbstractController
$input->imdbId
);
return $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getMovieOptionsHandler->handle($input->toCommand());
return $this->getMovieOptionsHandler->handle($input->toCommand());
});
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
@@ -46,7 +49,6 @@ final class WebController extends AbstractController
return $this->render('torrentio/movies.html.twig', [
'results' => $results,
]);
});
}
#[Route('/torrentio/tvshows/{tmdbId}/{imdbId}/{season?}/{episode?}', name: 'app_torrentio_tvshows')]
@@ -61,9 +63,10 @@ final class WebController extends AbstractController
);
try {
return $cache->get($cacheId, function (ItemInterface $item) use ($input, $request) {
$results = $cache->get($cacheId, function (ItemInterface $item) use ($input) {
$item->expiresAt(Carbon::now()->addHour()->setMinute(0)->setSecond(0));
$results = $this->getTvShowOptionsHandler->handle($input->toCommand());
return $this->getTvShowOptionsHandler->handle($input->toCommand());
});
if ($request->headers->get('Turbo-Frame')) {
return $this->sendFragmentResponse($results, $request);
@@ -72,7 +75,6 @@ final class WebController extends AbstractController
return $this->render('torrentio/tvshows.html.twig', [
'results' => $results,
]);
});
} catch (TorrentioRateLimitException $exception) {
$this->broadcaster->alert('Warning', 'Torrentio has rate limited your requests. Please wait a few minutes before trying again.', 'warning');
return $this->render('bare.html.twig',

View File

@@ -44,7 +44,12 @@
{% set delete_button = component('ux:icon', {name: 'ic:twotone-cancel', height: '17.75px', width: '17.75px', class: 'rounded-full align-middle text-red-600 hover:text-red-700' }) %}
<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>?
<p class="mb-1">Are you sure you want to delete the following record?</p>
<p class="mb-1 ml-4 italic">{{ download.filename }}</p>
<div class="">
<input id="delete_file_{{ download.id }}" class="accent-orange-500" type="checkbox" value="false" name="delete_file" />
<label for="delete_file_{{ download.id }}">Delete the file as well?</label>
</div>
</twig:Modal>
</td>
</tr>

View File

@@ -13,7 +13,9 @@
{% block tvshow_results %}
<turbo-stream action="replace" targets="#{{ target }}">
<template>
<div id="{{ target }}">
{{ include('torrentio/partial/option-table.html.twig', {controller: 'tv-results'}) }}
</div>
</template>
</turbo-stream>
{% endblock %}