mirror of
https://github.com/AzuraCast/AzuraCast.git
synced 2024-06-23 01:17:05 +00:00
Storage Quota Overhaul
- Adds enforcement of storage location quotas to album art uploads, podcast uploads, broadcast recordings and backups. - Removes PodcastMediaRepository to avoid a circular dependency problem - All storage locations will periodically update their "space used" via a cron task - Adds a quota display to the podcast management page
This commit is contained in:
parent
0da15085d3
commit
cbb35d55a1
|
@ -1,14 +1,23 @@
|
|||
<template>
|
||||
<div>
|
||||
<b-card no-body>
|
||||
<b-card-header header-bg-variant="primary-dark" class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 pr-3">
|
||||
<album-art :src="podcast.art"></album-art>
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
<h2 class="card-title">{{ podcast.title }}</h2>
|
||||
<h4 class="card-subtitle text-muted" key="lang_episodes" v-translate>Episodes</h4>
|
||||
</div>
|
||||
<b-card-header header-bg-variant="primary-dark">
|
||||
<b-row class="row align-items-center">
|
||||
<b-col md="7">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0 pr-3">
|
||||
<album-art :src="podcast.art"></album-art>
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
<h2 class="card-title">{{ podcast.title }}</h2>
|
||||
<h4 class="card-subtitle text-muted" key="lang_episodes" v-translate>Episodes</h4>
|
||||
</div>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col md="5" class="text-right text-white-50">
|
||||
<stations-common-quota :quota-url="quotaUrl" ref="quota"></stations-common-quota>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-card-header>
|
||||
|
||||
<b-card-body body-class="card-padding-sm">
|
||||
|
@ -74,23 +83,25 @@ import Icon from '~/components/Common/Icon';
|
|||
import AlbumArt from '~/components/Common/AlbumArt';
|
||||
import EpisodeFormBasicInfo from './EpisodeForm/BasicInfo';
|
||||
import PodcastCommonArtwork from './Common/Artwork';
|
||||
import StationsCommonQuota from "~/components/Stations/Common/Quota";
|
||||
|
||||
export const episodeViewProps = {
|
||||
props: {
|
||||
locale: String,
|
||||
stationTimeZone: String
|
||||
stationTimeZone: String,
|
||||
quotaUrl: String
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'EpisodesView',
|
||||
components: { PodcastCommonArtwork, EpisodeFormBasicInfo, AlbumArt, Icon, EditModal, DataTable },
|
||||
components: {StationsCommonQuota, PodcastCommonArtwork, EpisodeFormBasicInfo, AlbumArt, Icon, EditModal, DataTable},
|
||||
mixins: [episodeViewProps],
|
||||
props: {
|
||||
podcast: Object
|
||||
},
|
||||
emits: ['clear-podcast'],
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
fields: [
|
||||
{key: 'art', label: this.$gettext('Art'), sortable: false, class: 'shrink pr-0'},
|
||||
|
@ -103,6 +114,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
relist () {
|
||||
this.$refs.quota.update();
|
||||
if (this.$refs.datatable) {
|
||||
this.$refs.datatable.refresh();
|
||||
}
|
||||
|
|
|
@ -3,9 +3,12 @@
|
|||
<b-card no-body>
|
||||
<b-card-header header-bg-variant="primary-dark">
|
||||
<b-row class="align-items-center">
|
||||
<b-col md="6">
|
||||
<b-col md="7">
|
||||
<h2 class="card-title" key="lang_podcasts" v-translate>Podcasts</h2>
|
||||
</b-col>
|
||||
<b-col md="5" class="text-right text-white-50">
|
||||
<stations-common-quota :quota-url="quotaUrl" ref="quota"></stations-common-quota>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-card-header>
|
||||
|
||||
|
@ -63,11 +66,13 @@
|
|||
import DataTable from '~/components/Common/DataTable';
|
||||
import EditModal from './PodcastEditModal';
|
||||
import AlbumArt from '~/components/Common/AlbumArt';
|
||||
import StationsCommonQuota from "~/components/Stations/Common/Quota";
|
||||
|
||||
export const listViewProps = {
|
||||
props: {
|
||||
listUrl: String,
|
||||
newArtUrl: String,
|
||||
quotaUrl: String,
|
||||
locale: String,
|
||||
stationTimeZone: String,
|
||||
languageOptions: Object,
|
||||
|
@ -77,16 +82,16 @@ export const listViewProps = {
|
|||
|
||||
export default {
|
||||
name: 'ListView',
|
||||
components: { AlbumArt, EditModal, DataTable },
|
||||
components: {StationsCommonQuota, AlbumArt, EditModal, DataTable},
|
||||
mixins: [listViewProps],
|
||||
emits: ['select-podcast'],
|
||||
data () {
|
||||
data() {
|
||||
return {
|
||||
fields: [
|
||||
{ key: 'art', label: this.$gettext('Art'), sortable: false, class: 'shrink pr-0' },
|
||||
{ key: 'title', label: this.$gettext('Podcast'), sortable: false },
|
||||
{ key: 'num_episodes', label: this.$gettext('# Episodes'), sortable: false },
|
||||
{ key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink' }
|
||||
{key: 'art', label: this.$gettext('Art'), sortable: false, class: 'shrink pr-0'},
|
||||
{key: 'title', label: this.$gettext('Podcast'), sortable: false},
|
||||
{key: 'num_episodes', label: this.$gettext('# Episodes'), sortable: false},
|
||||
{key: 'actions', label: this.$gettext('Actions'), sortable: false, class: 'shrink'}
|
||||
]
|
||||
};
|
||||
},
|
||||
|
@ -100,6 +105,7 @@ export default {
|
|||
return episodes.length;
|
||||
},
|
||||
relist () {
|
||||
this.$refs.quota.update();
|
||||
if (this.$refs.datatable) {
|
||||
this.$refs.datatable.refresh();
|
||||
}
|
||||
|
|
|
@ -52,6 +52,11 @@ class BackupCommand extends CommandAbstract
|
|||
$io->error('Invalid storage location specified.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($storageLocation->isStorageFull()) {
|
||||
$io->error('Storage location is full.');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$includeMedia = !$excludeMedia;
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace App\Controller\Api\Stations\Files;
|
|||
|
||||
use App\Entity;
|
||||
use App\Exception\CannotProcessMediaException;
|
||||
use App\Exception\StorageLocationFullException;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Service\Flow;
|
||||
|
@ -27,11 +28,7 @@ class FlowUploadAction
|
|||
$station = $request->getStation();
|
||||
|
||||
$mediaStorage = $station->getMediaStorageLocation();
|
||||
|
||||
if ($mediaStorage->isStorageFull()) {
|
||||
return $response->withStatus(500)
|
||||
->withJson(new Entity\Api\Error(500, __('This station is out of available storage space.')));
|
||||
}
|
||||
$mediaStorage->errorIfFull();
|
||||
|
||||
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
|
||||
if ($flowResponse instanceof ResponseInterface) {
|
||||
|
@ -47,6 +44,10 @@ class FlowUploadAction
|
|||
|
||||
$uploadedSize = $flowResponse->getSize();
|
||||
|
||||
if (!$mediaStorage->canHoldFile($uploadedSize)) {
|
||||
throw new StorageLocationFullException();
|
||||
}
|
||||
|
||||
try {
|
||||
$stationMedia = $mediaRepo->getOrCreate($station, $destPath, $flowResponse->getUploadedPath());
|
||||
} catch (CannotProcessMediaException $e) {
|
||||
|
|
|
@ -27,6 +27,7 @@ class GetQuotaAction
|
|||
|
||||
$numFiles = match ($type) {
|
||||
Entity\StorageLocation::TYPE_STATION_MEDIA => $this->getNumStationMedia($station),
|
||||
Entity\StorageLocation::TYPE_STATION_PODCASTS => $this->getNumStationPodcastMedia($station),
|
||||
default => null,
|
||||
};
|
||||
|
||||
|
@ -53,4 +54,15 @@ class GetQuotaAction
|
|||
)->setParameter('storageLocation', $station->getMediaStorageLocation())
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
|
||||
protected function getNumStationPodcastMedia(Entity\Station $station): int
|
||||
{
|
||||
return (int)$this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT COUNT(pm.id) FROM App\Entity\PodcastMedia pm
|
||||
WHERE pm.storage_location = :storageLocation
|
||||
DQL
|
||||
)->setParameter('storageLocation', $station->getPodcastsStorageLocation())
|
||||
->getSingleScalarResult();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,7 +31,6 @@ class PodcastEpisodesController extends AbstractApiCrudController
|
|||
ValidatorInterface $validator,
|
||||
protected Entity\Repository\StationRepository $stationRepository,
|
||||
protected Entity\Repository\PodcastRepository $podcastRepository,
|
||||
protected Entity\Repository\PodcastMediaRepository $podcastMediaRepository,
|
||||
protected Entity\Repository\PodcastEpisodeRepository $episodeRepository
|
||||
) {
|
||||
parent::__construct($em, $serializer, $validator);
|
||||
|
@ -233,7 +232,7 @@ class PodcastEpisodesController extends AbstractApiCrudController
|
|||
if (!empty($parsedBody['media_file'])) {
|
||||
$media = UploadedFile::fromArray($parsedBody['media_file'], $station->getRadioTempDir());
|
||||
|
||||
$this->podcastMediaRepository->upload(
|
||||
$this->episodeRepository->uploadMedia(
|
||||
$record,
|
||||
$media->getClientFilename(),
|
||||
$media->getUploadedPath()
|
||||
|
|
|
@ -20,6 +20,9 @@ class PostArtAction
|
|||
): ResponseInterface {
|
||||
$station = $request->getStation();
|
||||
|
||||
$mediaStorage = $station->getPodcastsStorageLocation();
|
||||
$mediaStorage->errorIfFull();
|
||||
|
||||
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
|
||||
if ($flowResponse instanceof ResponseInterface) {
|
||||
return $flowResponse;
|
||||
|
|
|
@ -14,7 +14,6 @@ class DeleteMediaAction
|
|||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
Entity\Repository\PodcastMediaRepository $mediaRepo,
|
||||
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
|
||||
string $episode_id
|
||||
): ResponseInterface {
|
||||
|
@ -29,7 +28,7 @@ class DeleteMediaAction
|
|||
$podcastMedia = $episode->getMedia();
|
||||
|
||||
if ($podcastMedia instanceof Entity\PodcastMedia) {
|
||||
$mediaRepo->delete($podcastMedia);
|
||||
$episodeRepo->deleteMedia($podcastMedia);
|
||||
}
|
||||
|
||||
return $response->withJson(Entity\Api\Status::deleted());
|
||||
|
|
|
@ -17,7 +17,6 @@ class PostMediaAction
|
|||
ServerRequest $request,
|
||||
Response $response,
|
||||
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
|
||||
Entity\Repository\PodcastMediaRepository $mediaRepo,
|
||||
string $podcast_id,
|
||||
?string $episode_id = null
|
||||
): ResponseInterface {
|
||||
|
@ -37,7 +36,7 @@ class PostMediaAction
|
|||
}
|
||||
|
||||
$fsStation = new StationFilesystems($station);
|
||||
$mediaRepo->upload(
|
||||
$episodeRepo->uploadMedia(
|
||||
$episode,
|
||||
$flowResponse->getClientFilename(),
|
||||
$flowResponse->getUploadedPath(),
|
||||
|
|
|
@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller\Stations;
|
||||
|
||||
use App\Entity\PodcastCategory;
|
||||
use App\Entity;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
@ -21,7 +21,7 @@ class PodcastsAction
|
|||
$userLocale = (string)$request->getCustomization()->getLocale();
|
||||
|
||||
$languageOptions = Languages::getNames($userLocale);
|
||||
$categoriesOptions = PodcastCategory::getAvailableCategories();
|
||||
$categoriesOptions = Entity\PodcastCategory::getAvailableCategories();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
|
@ -29,12 +29,15 @@ class PodcastsAction
|
|||
id: 'station-podcasts',
|
||||
title: __('Podcasts'),
|
||||
props: [
|
||||
'listUrl' => (string)$router->fromHere('api:stations:podcasts'),
|
||||
'newArtUrl' => (string)$router->fromHere('api:stations:podcasts:new-art'),
|
||||
'stationUrl' => (string)$router->fromHere('stations:index:index'),
|
||||
'locale' => substr((string)$customization->getLocale(), 0, 2),
|
||||
'stationTimeZone' => $station->getTimezone(),
|
||||
'languageOptions' => $languageOptions,
|
||||
'listUrl' => (string)$router->fromHere('api:stations:podcasts'),
|
||||
'newArtUrl' => (string)$router->fromHere('api:stations:podcasts:new-art'),
|
||||
'stationUrl' => (string)$router->fromHere('stations:index:index'),
|
||||
'quotaUrl' => (string)$router->fromHere('api:stations:quota', [
|
||||
'type' => Entity\StorageLocation::TYPE_STATION_PODCASTS,
|
||||
]),
|
||||
'locale' => substr((string)$customization->getLocale(), 0, 2),
|
||||
'stationTimeZone' => $station->getTimezone(),
|
||||
'languageOptions' => $languageOptions,
|
||||
'categoriesOptions' => $categoriesOptions,
|
||||
],
|
||||
);
|
||||
|
|
|
@ -12,11 +12,9 @@ use Symfony\Component\Finder\Finder;
|
|||
|
||||
class PodcastEpisode extends AbstractFixture implements DependentFixtureInterface
|
||||
{
|
||||
protected Entity\Repository\PodcastMediaRepository $mediaRepo;
|
||||
|
||||
public function __construct(Entity\Repository\PodcastMediaRepository $mediaRepo)
|
||||
{
|
||||
$this->mediaRepo = $mediaRepo;
|
||||
public function __construct(
|
||||
protected Entity\Repository\PodcastEpisodeRepository $episodeRepo
|
||||
) {
|
||||
}
|
||||
|
||||
public function load(ObjectManager $manager): void
|
||||
|
@ -76,7 +74,7 @@ class PodcastEpisode extends AbstractFixture implements DependentFixtureInterfac
|
|||
$manager->persist($episode);
|
||||
$manager->flush();
|
||||
|
||||
$this->mediaRepo->upload(
|
||||
$this->episodeRepo->uploadMedia(
|
||||
$episode,
|
||||
$fileBaseName,
|
||||
$tempPath,
|
||||
|
|
|
@ -8,10 +8,14 @@ use App\Doctrine\ReloadableEntityManagerInterface;
|
|||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Exception\InvalidPodcastMediaFileException;
|
||||
use App\Exception\StorageLocationFullException;
|
||||
use App\Media\MetadataManager;
|
||||
use Azura\Files\ExtendedFilesystemInterface;
|
||||
use Intervention\Image\Constraint;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToRetrieveMetadata;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
|
@ -21,11 +25,12 @@ use Symfony\Component\Serializer\Serializer;
|
|||
class PodcastEpisodeRepository extends Repository
|
||||
{
|
||||
public function __construct(
|
||||
protected ImageManager $imageManager,
|
||||
protected MetadataManager $metadataManager,
|
||||
ReloadableEntityManagerInterface $entityManager,
|
||||
Serializer $serializer,
|
||||
Environment $environment,
|
||||
LoggerInterface $logger,
|
||||
protected ImageManager $imageManager
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct($entityManager, $serializer, $environment, $logger);
|
||||
}
|
||||
|
@ -89,13 +94,25 @@ class PodcastEpisodeRepository extends Repository
|
|||
}
|
||||
);
|
||||
|
||||
$episodeArtworkPath = Entity\PodcastEpisode::getArtPath($episode->getIdRequired());
|
||||
$episodeArtworkStream = $episodeArtwork->stream('jpg');
|
||||
$episodeArtwork->encode('jpg');
|
||||
$episodeArtworkString = $episodeArtwork->getEncoded();
|
||||
|
||||
$fsPodcasts = $episode->getPodcast()->getStorageLocation()->getFilesystem();
|
||||
$fsPodcasts->writeStream($episodeArtworkPath, $episodeArtworkStream->detach());
|
||||
$storageLocation = $episode->getPodcast()->getStorageLocation();
|
||||
$fs = $storageLocation->getFilesystem();
|
||||
|
||||
$episodeArtworkSize = strlen($episodeArtworkString);
|
||||
if (!$storageLocation->canHoldFile($episodeArtworkSize)) {
|
||||
throw new StorageLocationFullException();
|
||||
}
|
||||
|
||||
$episodeArtworkPath = Entity\PodcastEpisode::getArtPath($episode->getIdRequired());
|
||||
$fs->write($episodeArtworkPath, $episodeArtworkString);
|
||||
|
||||
$storageLocation->addStorageUsed($episodeArtworkSize);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$episode->setArtUpdatedAt(time());
|
||||
$this->em->persist($episode);
|
||||
}
|
||||
|
||||
public function removeEpisodeArt(
|
||||
|
@ -104,30 +121,129 @@ class PodcastEpisodeRepository extends Repository
|
|||
): void {
|
||||
$artworkPath = Entity\PodcastEpisode::getArtPath($episode->getIdRequired());
|
||||
|
||||
$fs ??= $episode->getPodcast()->getStorageLocation()->getFilesystem();
|
||||
$storageLocation = $episode->getPodcast()->getStorageLocation();
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
try {
|
||||
$size = $fs->fileSize($artworkPath);
|
||||
} catch (UnableToRetrieveMetadata) {
|
||||
$size = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$fs->delete($artworkPath);
|
||||
} catch (UnableToDeleteFile) {
|
||||
}
|
||||
|
||||
$storageLocation->removeStorageUsed($size);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$episode->setArtUpdatedAt(0);
|
||||
$this->em->persist($episode);
|
||||
}
|
||||
|
||||
public function uploadMedia(
|
||||
Entity\PodcastEpisode $episode,
|
||||
string $originalPath,
|
||||
string $uploadPath,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$podcast = $episode->getPodcast();
|
||||
$storageLocation = $podcast->getStorageLocation();
|
||||
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
$size = filesize($uploadPath) ?: 0;
|
||||
if (!$storageLocation->canHoldFile($size)) {
|
||||
throw new StorageLocationFullException();
|
||||
}
|
||||
|
||||
// Do an early metadata check of the new media to avoid replacing a valid file with an invalid one.
|
||||
$metadata = $this->metadataManager->read($uploadPath);
|
||||
|
||||
if (!in_array($metadata->getMimeType(), ['audio/x-m4a', 'audio/mpeg'])) {
|
||||
throw new InvalidPodcastMediaFileException(
|
||||
'Invalid Podcast Media mime type: ' . $metadata->getMimeType()
|
||||
);
|
||||
}
|
||||
|
||||
$existingMedia = $episode->getMedia();
|
||||
if ($existingMedia instanceof Entity\PodcastMedia) {
|
||||
$this->deleteMedia($existingMedia, $fs);
|
||||
$episode->setMedia(null);
|
||||
}
|
||||
|
||||
$ext = pathinfo($originalPath, PATHINFO_EXTENSION);
|
||||
$path = $podcast->getId() . '/' . $episode->getId() . '.' . $ext;
|
||||
|
||||
$podcastMedia = new Entity\PodcastMedia($storageLocation);
|
||||
$podcastMedia->setPath($path);
|
||||
$podcastMedia->setOriginalName(basename($originalPath));
|
||||
|
||||
// Load metadata from local file while it's available.
|
||||
$podcastMedia->setLength($metadata->getDuration());
|
||||
$podcastMedia->setMimeType($metadata->getMimeType());
|
||||
|
||||
// Upload local file remotely.
|
||||
$fs->uploadAndDeleteOriginal($uploadPath, $path);
|
||||
|
||||
$podcastMedia->setEpisode($episode);
|
||||
$this->em->persist($podcastMedia);
|
||||
|
||||
$storageLocation->addStorageUsed($size);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$episode->setMedia($podcastMedia);
|
||||
|
||||
$artwork = $metadata->getArtwork();
|
||||
if (!empty($artwork) && 0 === $episode->getArtUpdatedAt()) {
|
||||
$this->writeEpisodeArt(
|
||||
$episode,
|
||||
$artwork
|
||||
);
|
||||
}
|
||||
|
||||
$this->em->persist($episode);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function deleteMedia(
|
||||
Entity\PodcastMedia $media,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$storageLocation = $media->getStorageLocation();
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
$mediaPath = $media->getPath();
|
||||
|
||||
try {
|
||||
$size = $fs->fileSize($mediaPath);
|
||||
} catch (UnableToRetrieveMetadata) {
|
||||
$size = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$fs->delete($mediaPath);
|
||||
} catch (UnableToDeleteFile) {
|
||||
}
|
||||
|
||||
$storageLocation->removeStorageUsed($size);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$this->em->remove($media);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function delete(
|
||||
Entity\PodcastEpisode $episode,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$fs ??= $episode->getPodcast()->getStorageLocation()->getFilesystem();
|
||||
$storageLocation = $episode->getPodcast()->getStorageLocation();
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
$media = $episode->getMedia();
|
||||
if (null !== $media) {
|
||||
try {
|
||||
$fs->delete($media->getPath());
|
||||
} catch (UnableToDeleteFile) {
|
||||
}
|
||||
|
||||
$this->em->remove($media);
|
||||
$this->deleteMedia($media, $fs);
|
||||
}
|
||||
|
||||
$this->removeEpisodeArt($episode, $fs);
|
||||
|
|
|
@ -1,107 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\ReloadableEntityManagerInterface;
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Exception\InvalidPodcastMediaFileException;
|
||||
use App\Media\MetadataManager;
|
||||
use Azura\Files\ExtendedFilesystemInterface;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
/**
|
||||
* @extends Repository<Entity\PodcastMedia>
|
||||
*/
|
||||
class PodcastMediaRepository extends Repository
|
||||
{
|
||||
public function __construct(
|
||||
ReloadableEntityManagerInterface $em,
|
||||
Serializer $serializer,
|
||||
Environment $environment,
|
||||
LoggerInterface $logger,
|
||||
protected MetadataManager $metadataManager,
|
||||
protected ImageManager $imageManager,
|
||||
protected PodcastEpisodeRepository $episodeRepo,
|
||||
) {
|
||||
parent::__construct($em, $serializer, $environment, $logger);
|
||||
}
|
||||
|
||||
public function upload(
|
||||
Entity\PodcastEpisode $episode,
|
||||
string $originalPath,
|
||||
string $uploadPath,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$podcast = $episode->getPodcast();
|
||||
$storageLocation = $podcast->getStorageLocation();
|
||||
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
// Do an early metadata check of the new media to avoid replacing a valid file with an invalid one.
|
||||
$metadata = $this->metadataManager->read($uploadPath);
|
||||
|
||||
if (!in_array($metadata->getMimeType(), ['audio/x-m4a', 'audio/mpeg'])) {
|
||||
throw new InvalidPodcastMediaFileException(
|
||||
'Invalid Podcast Media mime type: ' . $metadata->getMimeType()
|
||||
);
|
||||
}
|
||||
|
||||
$existingMedia = $episode->getMedia();
|
||||
if ($existingMedia instanceof Entity\PodcastMedia) {
|
||||
$this->delete($existingMedia, $fs);
|
||||
$episode->setMedia(null);
|
||||
}
|
||||
|
||||
$ext = pathinfo($originalPath, PATHINFO_EXTENSION);
|
||||
$path = $podcast->getId() . '/' . $episode->getId() . '.' . $ext;
|
||||
|
||||
$podcastMedia = new Entity\PodcastMedia($storageLocation);
|
||||
$podcastMedia->setPath($path);
|
||||
$podcastMedia->setOriginalName(basename($originalPath));
|
||||
|
||||
// Load metadata from local file while it's available.
|
||||
$podcastMedia->setLength($metadata->getDuration());
|
||||
$podcastMedia->setMimeType($metadata->getMimeType());
|
||||
|
||||
// Upload local file remotely.
|
||||
$fs->uploadAndDeleteOriginal($uploadPath, $path);
|
||||
|
||||
$podcastMedia->setEpisode($episode);
|
||||
$this->em->persist($podcastMedia);
|
||||
|
||||
$episode->setMedia($podcastMedia);
|
||||
|
||||
$artwork = $metadata->getArtwork();
|
||||
if (!empty($artwork) && 0 === $episode->getArtUpdatedAt()) {
|
||||
$this->episodeRepo->writeEpisodeArt(
|
||||
$episode,
|
||||
$artwork
|
||||
);
|
||||
}
|
||||
|
||||
$this->em->persist($episode);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function delete(
|
||||
Entity\PodcastMedia $media,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$fs ??= $media->getStorageLocation()->getFilesystem();
|
||||
|
||||
try {
|
||||
$fs->delete($media->getPath());
|
||||
} catch (UnableToDeleteFile) {
|
||||
}
|
||||
|
||||
$this->em->remove($media);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
|
@ -8,10 +8,12 @@ use App\Doctrine\ReloadableEntityManagerInterface;
|
|||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Exception\StorageLocationFullException;
|
||||
use Azura\Files\ExtendedFilesystemInterface;
|
||||
use Intervention\Image\Constraint;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToRetrieveMetadata;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
|
@ -82,7 +84,8 @@ class PodcastRepository extends Repository
|
|||
string $rawArtworkString,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$fs ??= $podcast->getStorageLocation()->getFilesystem();
|
||||
$storageLocation = $podcast->getStorageLocation();
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
$podcastArtwork = $this->imageManager->make($rawArtworkString);
|
||||
$podcastArtwork->fit(
|
||||
|
@ -92,29 +95,49 @@ class PodcastRepository extends Repository
|
|||
$constraint->upsize();
|
||||
}
|
||||
);
|
||||
$podcastArtwork->encode('jpg');
|
||||
$podcastArtworkString = $podcastArtwork->getEncoded();
|
||||
|
||||
$podcastArtworkSize = strlen($podcastArtworkString);
|
||||
if (!$storageLocation->canHoldFile($podcastArtworkSize)) {
|
||||
throw new StorageLocationFullException();
|
||||
}
|
||||
|
||||
$podcastArtworkPath = Entity\Podcast::getArtPath($podcast->getIdRequired());
|
||||
$podcastArtworkStream = $podcastArtwork->stream('jpg');
|
||||
$fs->write($podcastArtworkPath, $podcastArtworkString);
|
||||
|
||||
$fs->writeStream($podcastArtworkPath, $podcastArtworkStream->detach());
|
||||
$storageLocation->addStorageUsed($podcastArtworkSize);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$podcast->setArtUpdatedAt(time());
|
||||
$this->em->persist($podcast);
|
||||
}
|
||||
|
||||
public function removePodcastArt(
|
||||
Entity\Podcast $podcast,
|
||||
?ExtendedFilesystemInterface $fs = null
|
||||
): void {
|
||||
$fs ??= $podcast->getStorageLocation()->getFilesystem();
|
||||
$storageLocation = $podcast->getStorageLocation();
|
||||
$fs ??= $storageLocation->getFilesystem();
|
||||
|
||||
$artworkPath = Entity\Podcast::getArtPath($podcast->getIdRequired());
|
||||
|
||||
try {
|
||||
$size = $fs->fileSize($artworkPath);
|
||||
} catch (UnableToRetrieveMetadata) {
|
||||
$size = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$fs->delete($artworkPath);
|
||||
} catch (UnableToDeleteFile) {
|
||||
}
|
||||
|
||||
$storageLocation->removeStorageUsed($size);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$podcast->setArtUpdatedAt(0);
|
||||
$this->em->persist($podcast);
|
||||
}
|
||||
|
||||
public function delete(
|
||||
|
|
|
@ -114,8 +114,21 @@ class StationStreamerRepository extends Repository
|
|||
$broadcastPath = $broadcast->getRecordingPath();
|
||||
|
||||
if ((null !== $broadcastPath) && $fsTemp->fileExists($broadcastPath)) {
|
||||
$recordingsStorageLocation = $station->getRecordingsStorageLocation();
|
||||
|
||||
$tempPath = $fsTemp->getLocalPath($broadcastPath);
|
||||
$fsRecordings->uploadAndDeleteOriginal($tempPath, $broadcastPath);
|
||||
if ($recordingsStorageLocation->canHoldFile($fsTemp->fileSize($broadcastPath))) {
|
||||
$fsRecordings->uploadAndDeleteOriginal($tempPath, $broadcastPath);
|
||||
} else {
|
||||
$this->logger->error(
|
||||
'Storage location full; broadcast not moved to storage location. '
|
||||
. 'Check temporary directory at path to recover file.',
|
||||
[
|
||||
'storageLocation' => (string)$recordingsStorageLocation,
|
||||
'path' => $tempPath,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$broadcast->setTimestampEnd(time());
|
||||
|
@ -126,6 +139,7 @@ class StationStreamerRepository extends Repository
|
|||
$station->setCurrentStreamer();
|
||||
$this->em->persist($station);
|
||||
$this->em->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace App\Entity\Repository;
|
|||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity;
|
||||
use Brick\Math\BigInteger;
|
||||
|
||||
/**
|
||||
* @extends Repository<Entity\StorageLocation>
|
||||
|
@ -17,7 +18,7 @@ class StorageLocationRepository extends Repository
|
|||
return $this->repository->findOneBy(
|
||||
[
|
||||
'type' => $type,
|
||||
'id' => $id,
|
||||
'id' => $id,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
@ -112,4 +113,22 @@ class StorageLocationRepository extends Repository
|
|||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
public function addStorageUsed(
|
||||
Entity\StorageLocation $storageLocation,
|
||||
BigInteger|int|string $newStorageAmount
|
||||
): void {
|
||||
$storageLocation->addStorageUsed($newStorageAmount);
|
||||
$this->em->persist($storageLocation);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function removeStorageUsed(
|
||||
Entity\StorageLocation $storageLocation,
|
||||
BigInteger|int|string $amountToRemove
|
||||
): void {
|
||||
$storageLocation->removeStorageUsed($amountToRemove);
|
||||
$this->em->persist($storageLocation);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Interfaces\IdentifiableEntityInterface;
|
||||
use App\Exception\StorageLocationFullException;
|
||||
use App\Radio\Quota;
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Aws\S3\S3Client;
|
||||
|
@ -349,6 +350,28 @@ class StorageLocation implements Stringable, IdentifiableEntityInterface
|
|||
return ($used->compareTo($available) !== -1);
|
||||
}
|
||||
|
||||
public function canHoldFile(BigInteger|int|string $size): bool
|
||||
{
|
||||
if (empty($size)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$available = $this->getStorageAvailableBytes();
|
||||
if ($available === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$newStorageUsed = $this->getStorageUsedBytes()->plus($size);
|
||||
return ($newStorageUsed->compareTo($available) === -1);
|
||||
}
|
||||
|
||||
public function errorIfFull(): void
|
||||
{
|
||||
if ($this->isStorageFull()) {
|
||||
throw new StorageLocationFullException();
|
||||
}
|
||||
}
|
||||
|
||||
public function getStorageUsePercentage(): int
|
||||
{
|
||||
$storageUsed = $this->getStorageUsedBytes();
|
||||
|
|
21
src/Exception/StorageLocationFullException.php
Normal file
21
src/Exception/StorageLocationFullException.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use App\Exception;
|
||||
use Psr\Log\LogLevel;
|
||||
use Throwable;
|
||||
|
||||
class StorageLocationFullException extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $message = 'Storage location is full.',
|
||||
int $code = 0,
|
||||
Throwable $previous = null,
|
||||
string $loggerLevel = LogLevel::INFO
|
||||
) {
|
||||
parent::__construct($message, $code, $previous, $loggerLevel);
|
||||
}
|
||||
}
|
66
src/Sync/Task/UpdateStorageLocationSizesTask.php
Normal file
66
src/Sync/Task/UpdateStorageLocationSizesTask.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Sync\Task;
|
||||
|
||||
use App\Entity;
|
||||
use App\Radio\Quota;
|
||||
use Azura\DoctrineBatchUtils\ReadWriteBatchIteratorAggregate;
|
||||
use Brick\Math\BigInteger;
|
||||
use Exception;
|
||||
use League\Flysystem\FileAttributes;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
|
||||
class UpdateStorageLocationSizesTask extends AbstractTask
|
||||
{
|
||||
public function run(bool $force = false): void
|
||||
{
|
||||
$iterator = ReadWriteBatchIteratorAggregate::fromQuery(
|
||||
$this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT sl
|
||||
FROM App\Entity\StorageLocation sl
|
||||
DQL
|
||||
),
|
||||
1
|
||||
);
|
||||
|
||||
foreach ($iterator as $storageLocation) {
|
||||
/** @var Entity\StorageLocation $storageLocation */
|
||||
$this->updateStorageLocationSize($storageLocation);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateStorageLocationSize(Entity\StorageLocation $storageLocation): void
|
||||
{
|
||||
$fs = $storageLocation->getFilesystem();
|
||||
|
||||
$used = BigInteger::zero();
|
||||
|
||||
try {
|
||||
/** @var StorageAttributes $row */
|
||||
foreach ($fs->listContents('', true) as $row) {
|
||||
if ($row->isFile()) {
|
||||
/** @var FileAttributes $row */
|
||||
$used = $used->plus($row->fileSize() ?? 0);
|
||||
}
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error(
|
||||
sprintf('Filesystem error: %s', $e->getMessage()),
|
||||
[
|
||||
'exception' => $e,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$storageLocation->setStorageUsed($used);
|
||||
$this->em->persist($storageLocation);
|
||||
|
||||
$this->logger->info('Storage location size updated.', [
|
||||
'storageLocation' => (string)$storageLocation,
|
||||
'size' => Quota::getReadableSize($used),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -36,6 +36,7 @@ class TaskLocator
|
|||
Task\CleanupLoginTokensTask::class,
|
||||
Task\CleanupHistoryTask::class,
|
||||
Task\CleanupStorageTask::class,
|
||||
Task\UpdateStorageLocationSizesTask::class,
|
||||
Task\RotateLogsTask::class,
|
||||
Task\UpdateGeoLiteTask::class,
|
||||
],
|
||||
|
|
Loading…
Reference in New Issue
Block a user