4
0
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:
Buster "Silver Eagle" Neece 2021-11-23 09:44:41 -06:00
parent 0da15085d3
commit cbb35d55a1
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
20 changed files with 383 additions and 170 deletions

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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) {

View File

@ -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();
}
}

View File

@ -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()

View File

@ -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;

View File

@ -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());

View File

@ -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(),

View File

@ -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,
],
);

View File

@ -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,

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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(

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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();

View 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);
}
}

View 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),
]);
}
}

View File

@ -36,6 +36,7 @@ class TaskLocator
Task\CleanupLoginTokensTask::class,
Task\CleanupHistoryTask::class,
Task\CleanupStorageTask::class,
Task\UpdateStorageLocationSizesTask::class,
Task\RotateLogsTask::class,
Task\UpdateGeoLiteTask::class,
],