From 5e722dcbc72a562851c50d93bfb97d619fbae767 Mon Sep 17 00:00:00 2001 From: Brock H Caldwell Date: Tue, 15 Jul 2025 23:53:19 -0500 Subject: [PATCH] fix: deletes media file when download deleted --- .gitignore | 1 + .../controllers/download_list_controller.js | 5 ++- src/Base/Service/MediaFiles.php | 26 ++++++++++++ .../Action/Command/DeleteDownloadCommand.php | 1 + .../Action/Handler/DeleteDownloadHandler.php | 17 +++++++- .../Action/Input/DeleteDownloadInput.php | 4 ++ .../Action/Result/DeleteDownloadResult.php | 1 + .../Action/Result/DeleteMediaFileResult.php | 14 +++++++ .../Framework/Controller/ApiController.php | 2 +- src/Download/Framework/Entity/Download.php | 2 + .../Action/Command/DeleteMediaFileCommand.php | 16 +++++++ .../Action/Handler/DeleteMediaFileHandler.php | 42 +++++++++++++++++++ .../Action/Input/DeleteMediaFileInput.php | 29 +++++++++++++ .../Action/Result/DeleteMediaFileResult.php | 15 +++++++ .../components/DownloadListRow.html.twig | 7 +++- templates/torrentio/fragments.html.twig | 4 +- 16 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 src/Download/Action/Result/DeleteMediaFileResult.php create mode 100644 src/Library/Action/Command/DeleteMediaFileCommand.php create mode 100644 src/Library/Action/Handler/DeleteMediaFileHandler.php create mode 100644 src/Library/Action/Input/DeleteMediaFileInput.php create mode 100644 src/Library/Action/Result/DeleteMediaFileResult.php diff --git a/.gitignore b/.gitignore index e44d252..8b1a0dd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ bolt.db ###> phpstan/phpstan ### phpstan.neon ###< phpstan/phpstan ### +.php-cs-fixer.cache diff --git a/assets/controllers/download_list_controller.js b/assets/controllers/download_list_controller.js index a3ea22d..cb7b6d5 100644 --- a/assets/controllers/download_list_controller.js +++ b/assets/controllers/download_list_controller.js @@ -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)); } diff --git a/src/Base/Service/MediaFiles.php b/src/Base/Service/MediaFiles.php index 7f80971..7a81e2a 100644 --- a/src/Base/Service/MediaFiles.php +++ b/src/Base/Service/MediaFiles.php @@ -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; + } } diff --git a/src/Download/Action/Command/DeleteDownloadCommand.php b/src/Download/Action/Command/DeleteDownloadCommand.php index 123095d..3a5affe 100644 --- a/src/Download/Action/Command/DeleteDownloadCommand.php +++ b/src/Download/Action/Command/DeleteDownloadCommand.php @@ -11,5 +11,6 @@ class DeleteDownloadCommand implements CommandInterface { public function __construct( public int $downloadId, + public bool $deleteFile = false, ) {} } \ No newline at end of file diff --git a/src/Download/Action/Handler/DeleteDownloadHandler.php b/src/Download/Action/Handler/DeleteDownloadHandler.php index 98c7e6b..4f139d9 100644 --- a/src/Download/Action/Handler/DeleteDownloadHandler.php +++ b/src/Download/Action/Handler/DeleteDownloadHandler.php @@ -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 + ); } } diff --git a/src/Download/Action/Input/DeleteDownloadInput.php b/src/Download/Action/Input/DeleteDownloadInput.php index 432f7e8..37975f4 100644 --- a/src/Download/Action/Input/DeleteDownloadInput.php +++ b/src/Download/Action/Input/DeleteDownloadInput.php @@ -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, ); } } \ No newline at end of file diff --git a/src/Download/Action/Result/DeleteDownloadResult.php b/src/Download/Action/Result/DeleteDownloadResult.php index 9a445ef..70eb35f 100644 --- a/src/Download/Action/Result/DeleteDownloadResult.php +++ b/src/Download/Action/Result/DeleteDownloadResult.php @@ -12,5 +12,6 @@ class DeleteDownloadResult implements ResultInterface public int $status, public string $message, public Download $download, + public ?DeleteMediaFileResult $deleteMediaFileResult = null, ) {} } diff --git a/src/Download/Action/Result/DeleteMediaFileResult.php b/src/Download/Action/Result/DeleteMediaFileResult.php new file mode 100644 index 0000000..6e761f2 --- /dev/null +++ b/src/Download/Action/Result/DeleteMediaFileResult.php @@ -0,0 +1,14 @@ +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'])] diff --git a/src/Download/Framework/Entity/Download.php b/src/Download/Framework/Entity/Download.php index 777029f..cb3231c 100644 --- a/src/Download/Framework/Entity/Download.php +++ b/src/Download/Framework/Entity/Download.php @@ -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; diff --git a/src/Library/Action/Command/DeleteMediaFileCommand.php b/src/Library/Action/Command/DeleteMediaFileCommand.php new file mode 100644 index 0000000..a2859ea --- /dev/null +++ b/src/Library/Action/Command/DeleteMediaFileCommand.php @@ -0,0 +1,16 @@ + + */ +class DeleteMediaFileCommand implements CommandInterface +{ + public function __construct( + public string $filename, + public ?int $downloadId = null, + ) {} +} diff --git a/src/Library/Action/Handler/DeleteMediaFileHandler.php b/src/Library/Action/Handler/DeleteMediaFileHandler.php new file mode 100644 index 0000000..5eea106 --- /dev/null +++ b/src/Library/Action/Handler/DeleteMediaFileHandler.php @@ -0,0 +1,42 @@ + + */ +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(); + } +} diff --git a/src/Library/Action/Input/DeleteMediaFileInput.php b/src/Library/Action/Input/DeleteMediaFileInput.php new file mode 100644 index 0000000..b1d3161 --- /dev/null +++ b/src/Library/Action/Input/DeleteMediaFileInput.php @@ -0,0 +1,29 @@ + + */ +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, + ); + } +} diff --git a/src/Library/Action/Result/DeleteMediaFileResult.php b/src/Library/Action/Result/DeleteMediaFileResult.php new file mode 100644 index 0000000..cdaf336 --- /dev/null +++ b/src/Library/Action/Result/DeleteMediaFileResult.php @@ -0,0 +1,15 @@ + - Are you sure you want to delete {{ download.filename }}? +

Are you sure you want to delete the following record?

+

{{ download.filename }}

+
+ + +
\ No newline at end of file diff --git a/templates/torrentio/fragments.html.twig b/templates/torrentio/fragments.html.twig index e4b221e..92d6c2a 100644 --- a/templates/torrentio/fragments.html.twig +++ b/templates/torrentio/fragments.html.twig @@ -13,7 +13,9 @@ {% block tvshow_results %} {% endblock %} \ No newline at end of file