Improve Flow upload handling and podcast media/artwork management.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-06-19 13:19:44 -05:00
parent 133a94380d
commit 913d2dfad2
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
22 changed files with 661 additions and 348 deletions

View File

@ -299,7 +299,7 @@ return function (App $app) {
$group->get(
'/download',
Controller\Api\Stations\Podcasts\Episodes\DownloadAction::class
Controller\Api\Stations\Podcasts\Episodes\Media\GetMediaAction::class
)->setName('api:stations:podcast:episode:download');
}
);
@ -313,50 +313,77 @@ return function (App $app) {
$group->get('', Controller\Api\Stations\PodcastsController::class . ':listAction')
->setName('api:stations:podcasts');
$group->post('', Controller\Api\Stations\PodcastsController::class . ':createAction')
->add(new Middleware\HandleMultipartJson());
$group->post('', Controller\Api\Stations\PodcastsController::class . ':createAction');
$group->post('/art', Controller\Api\Stations\Podcasts\Art\PostArtAction::class)
->setName('api:stations:podcasts:new-art');
}
)->add(new Middleware\Permissions(Acl::STATION_PODCASTS, true));
$group->group(
'/podcast/{podcast_id}',
function (RouteCollectorProxy $group) {
$group->map(
['PUT', 'POST'],
'',
Controller\Api\Stations\PodcastsController::class . ':editAction'
)->add(new Middleware\HandleMultipartJson());
$group->put('', Controller\Api\Stations\PodcastsController::class . ':editAction');
$group->delete('', Controller\Api\Stations\PodcastsController::class . ':deleteAction');
$group->post(
'/art',
Controller\Api\Stations\Podcasts\Art\PostArtAction::class
)->setName('api:stations:podcast:art-internal');
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Art\DeleteArtAction::class
)->setName('api:stations:podcast:art-internal');
);
$group->post(
'/episodes',
Controller\Api\Stations\PodcastEpisodesController::class . ':createAction'
)->add(new Middleware\HandleMultipartJson());
);
$group->post(
'/episodes/art',
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
)->setName('api:stations:podcast:episodes:new-art');
$group->post(
'/episodes/media',
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
)->setName('api:stations:podcast:episodes:new-media');
$group->group(
'/episode/{episode_id}',
function (RouteCollectorProxy $group) {
$group->map(
['PUT', 'POST'],
$group->put(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':editAction'
)->add(new Middleware\HandleMultipartJson());
);
$group->delete(
'',
Controller\Api\Stations\PodcastEpisodesController::class . ':deleteAction'
);
$group->post(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\PostArtAction::class
)->setName('api:stations:podcast:episode:art-internal');
$group->delete(
'/art',
Controller\Api\Stations\Podcasts\Episodes\Art\DeleteArtAction::class
)->setName('api:stations:podcast:episode:art-internal');
);
$group->post(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\PostMediaAction::class
)->setName('api:stations:podcast:episode:media-internal');
$group->delete(
'/media',
Controller\Api\Stations\Podcasts\Episodes\Media\DeleteMediaAction::class
);
}
);
}

View File

@ -2,30 +2,43 @@
<b-tab :title="langTitle">
<b-form-group>
<b-row>
<b-form-group class="col-md-8" label-for="artwork_file">
<template #label>
<translate key="artwork_file">Select PNG/JPG artwork file</translate>
</template>
<template #description>
<translate key="artwork_file_desc">Artwork must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels for Apple Podcasts.</translate>
</template>
<b-form-file id="artwork_file" accept="image/jpeg, image/png" v-model="form.artwork_file.$model" @input="updatePreviewArtwork"></b-form-file>
</b-form-group>
<b-col md="8">
<b-form-group label-for="edit_form_art">
<template #label>
<translate key="artwork_file">Select PNG/JPG artwork file</translate>
</template>
<template #description>
<translate key="artwork_file_desc">Artwork must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels for Apple Podcasts.</translate>
</template>
<b-form-file id="edit_form_art" accept="image/jpeg, image/png"
@input="uploadNewArt"></b-form-file>
</b-form-group>
</b-col>
<b-col md="4" v-if="src">
<b-img :src="src" :alt="langTitle" rounded fluid></b-img>
<b-form-group class="col-md-4" v-if="src">
<b-img fluid center :src="src" aria-hidden="true"></b-img>
</b-form-group>
<div class="buttons pt-3">
<b-button block variant="danger" @click="deleteArt">
<translate key="lang_btn_delete_art">Clear Artwork</translate>
</b-button>
</div>
</b-col>
</b-row>
</b-form-group>
</b-tab>
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../../Function/handleAxiosError';
export default {
name: 'PodcastCommonArtwork',
props: {
form: Object,
artworkSrc: String
value: Object,
artworkSrc: String,
editArtUrl: String,
newArtUrl: String
},
data () {
return {
@ -38,7 +51,7 @@ export default {
}
},
methods: {
updatePreviewArtwork (file) {
uploadNewArt (file) {
if (!(file instanceof File)) {
return;
}
@ -48,6 +61,31 @@ export default {
this.src = fileReader.result;
}, false);
fileReader.readAsDataURL(file);
let url = (this.editArtUrl) ? this.editArtUrl : this.newArtUrl;
let formData = new FormData();
formData.append('art', file);
axios.post(url, formData).then((resp) => {
if (this.editArtUrl) {
this.src = this.albumArtSrc + '?' + Math.floor(Date.now() / 1000);
} else {
this.$emit('input', resp.data);
}
}).catch((err) => {
handleAxiosError(err);
});
},
deleteArt () {
if (this.editArtUrl) {
axios.delete(this.editArtUrl).then((resp) => {
this.src = this.albumArtSrc + '?' + Math.floor(Date.now() / 1000);
}).catch((err) => {
handleAxiosError(err);
});
} else {
this.src = null;
}
}
}
};

View File

@ -6,8 +6,11 @@
<b-form class="form" v-else @submit.prevent="doSubmit">
<b-tabs content-class="mt-3">
<episode-form-basic-info :form="$v.form"></episode-form-basic-info>
<episode-form-media :form="$v.files" :has-media="record.has_media" :media="record.media" :download-url="record.links.download"></episode-form-media>
<podcast-common-artwork :form="$v.files" :artwork-src="record.art"></podcast-common-artwork>
<episode-form-media v-model="$v.form.media_file.$model" :record-has-media="record.has_media"
:new-media-url="newMediaUrl" :edit-media-url="record.links.media"
:download-url="record.links.download"></episode-form-media>
<podcast-common-artwork v-model="$v.form.artwork_file.$model" :artwork-src="record.art"
:new-art-url="newArtUrl" :edit-art-url="record.links.art"></podcast-common-artwork>
</b-tabs>
<invisible-submit-button/>
@ -16,11 +19,6 @@
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<template v-if="record.has_custom_art">
<b-button variant="danger" type="button" @click="clearArtwork(record.links.art)">
<translate key="lang_btn_clear_artwork">Clear Art</translate>
</b-button>
</template>
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="$v.form.$invalid">
{{ langSaveChanges }}
</b-button>
@ -29,14 +27,12 @@
</template>
<script>
import axios from 'axios';
import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import BaseEditModal from '../../Common/BaseEditModal';
import EpisodeFormBasicInfo from './EpisodeForm/BasicInfo';
import PodcastCommonArtwork from './Common/Artwork';
import EpisodeFormMedia from './EpisodeForm/Media';
import handleAxiosError from '../../Function/handleAxiosError';
export default {
name: 'EditModal',
@ -45,7 +41,9 @@ export default {
props: {
stationTimeZone: String,
locale: String,
podcastId: String
podcastId: String,
newArtUrl: String,
newMediaUrl: String
},
data () {
return {
@ -55,9 +53,10 @@ export default {
art: null,
has_media: false,
media: null,
links: {}
links: {},
artwork_file: null,
media_file: null
},
files: {}
};
},
computed: {
@ -83,9 +82,7 @@ export default {
'description': { required },
'publish_date': {},
'publish_time': {},
'explicit': {}
},
files: {
'explicit': {},
'artwork_file': {},
'media_file': {}
}
@ -98,7 +95,10 @@ export default {
art: null,
has_media: false,
media: null,
links: {}
links: {
art: null,
media: null
}
};
this.form = {
'title': '',
@ -106,9 +106,7 @@ export default {
'description': '',
'publish_date': '',
'publish_time': '',
'explicit': false
};
this.files = {
'explicit': false,
'artwork_file': null,
'media_file': null
};
@ -143,51 +141,16 @@ export default {
modifiedForm.publish_at = publishDateTime.unix();
}
let formData = new FormData();
formData.append('body', JSON.stringify(modifiedForm));
Object.entries(this.files).forEach(([key, value]) => {
if (null !== value) {
formData.append(key, value);
}
});
return {
method: 'POST',
method: (this.isEditMode)
? 'PUT'
: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
this.uploadPercentage = parseInt(Math.round((progressEvent.loaded / progressEvent.total) * 100));
}
data: this.form
};
},
clearArtwork (url) {
let buttonText = this.$gettext('Remove Artwork');
let buttonConfirmText = this.$gettext('Delete episode artwork?');
Swal.fire({
title: buttonConfirmText,
confirmButtonText: buttonText,
confirmButtonColor: '#e64942',
showCancelButton: true,
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$emit('relist');
this.close();
}).catch((err) => {
handleAxiosError(err);
});
}
});
},
}
}
};
</script>

View File

@ -9,7 +9,7 @@
<template #description>
<translate key="media_file_desc">Podcast media should be in the MP3 or M4A (AAC) format for the greatest compatibility.</translate>
</template>
<b-form-file id="media_file" accept="audio/x-m4a, audio/mpeg" v-model="form.media_file.$model"></b-form-file>
<b-form-file id="media_file" accept="audio/x-m4a, audio/mpeg" @input="uploadNewMedia"></b-form-file>
</b-form-group>
<b-form-group class="col-md-6">
@ -18,15 +18,13 @@
</template>
<div v-if="hasMedia">
<p>
{{ media.original_name }}<br>
<small>{{ media.length_text }}</small>
</p>
<div class="buttons">
<b-button :href="downloadUrl" target="_blank" variant="bg">
<div class="buttons pt-3">
<b-button v-if="downloadUrl" block variant="bg" :href="downloadUrl" target="_blank">
<translate key="btn_download">Download</translate>
</b-button>
<b-button block variant="danger" @click="deleteMedia">
<translate key="btn_delete_media">Clear Media</translate>
</b-button>
</div>
</div>
<div v-else>
@ -39,18 +37,59 @@
</template>
<script>
import axios from 'axios';
import handleAxiosError from '../../../Function/handleAxiosError';
export default {
name: 'EpisodeFormMedia',
props: {
form: Object,
hasMedia: Boolean,
media: Object,
downloadUrl: String
value: Object,
recordHasMedia: Boolean,
downloadUrl: String,
editMediaUrl: String,
newMediaUrl: String
},
data () {
return {
hasMedia: this.recordHasMedia
};
},
computed: {
langTitle () {
return this.$gettext('Media');
}
},
methods: {
uploadNewMedia (file) {
if (!(file instanceof File)) {
return;
}
let url = (this.editMediaUrl) ? this.editMediaUrl : this.newMediaUrl;
let formData = new FormData();
formData.append('art', file);
axios.post(url, formData).then((resp) => {
this.hasMedia = true;
if (!this.editMediaUrl) {
this.$emit('input', resp.data);
}
}).catch((err) => {
handleAxiosError(err);
});
},
deleteMedia () {
if (this.editMediaUrl) {
axios.delete(this.editMediaUrl).then((resp) => {
this.hasMedia = false;
}).catch((err) => {
handleAxiosError(err);
});
} else {
this.hasMedia = false;
this.$emit('input', null);
}
}
}
};
</script>

View File

@ -62,6 +62,7 @@
</b-card>
<edit-modal ref="editEpisodeModal" :create-url="podcast.links.episodes" :station-time-zone="stationTimeZone"
:new-art-url="podcast.links.episode_new_art" :new-media-url="podcast.links.episode_new_media"
:locale="locale" :podcast-id="podcast.id" @relist="relist"></edit-modal>
</div>
</template>

View File

@ -54,7 +54,8 @@
</b-card>
<edit-modal ref="editPodcastModal" :create-url="listUrl" :station-time-zone="stationTimeZone"
:language-options="languageOptions" :categories-options="categoriesOptions" @relist="relist"></edit-modal>
:new-art-url="newArtUrl" :language-options="languageOptions"
:categories-options="categoriesOptions" @relist="relist"></edit-modal>
</div>
</template>
@ -68,6 +69,7 @@ import handleAxiosError from '../../Function/handleAxiosError';
export const listViewProps = {
props: {
listUrl: String,
newArtUrl: String,
locale: String,
stationTimeZone: String,
languageOptions: Object,

View File

@ -8,7 +8,8 @@
<podcast-form-basic-info :form="$v.form"
:categories-options="categoriesOptions" :language-options="languageOptions">
</podcast-form-basic-info>
<podcast-common-artwork :form="$v.files" :artwork-src="record.art"></podcast-common-artwork>
<podcast-common-artwork v-model="$v.form.artwork_file.$model" :artwork-src="record.art"
:new-art-url="newArtUrl" :edit-art-url="record.links.art"></podcast-common-artwork>
</b-tabs>
<invisible-submit-button/>
@ -17,11 +18,6 @@
<b-button variant="default" type="button" @click="close">
<translate key="lang_btn_close">Close</translate>
</b-button>
<template v-if="record.has_custom_art">
<b-button variant="danger" type="button" @click="clearArtwork(record.links.art)">
<translate key="lang_btn_clear_artwork">Clear Art</translate>
</b-button>
</template>
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="$v.form.$invalid">
<translate key="lang_btn_save_changes">Save Changes</translate>
</b-button>
@ -30,13 +26,11 @@
</template>
<script>
import axios from 'axios';
import required from 'vuelidate/src/validators/required';
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
import BaseEditModal from '../../Common/BaseEditModal';
import PodcastFormBasicInfo from './PodcastForm/BasicInfo';
import PodcastCommonArtwork from './Common/Artwork';
import handleAxiosError from '../../Function/handleAxiosError';
export default {
name: 'EditModal',
@ -45,7 +39,8 @@ export default {
props: {
stationTimeZone: String,
languageOptions: Object,
categoriesOptions: Object
categoriesOptions: Object,
newArtUrl: String
},
data () {
return {
@ -59,9 +54,7 @@ export default {
'link': '',
'description': '',
'language': 'en',
'categories': []
},
files: {
'categories': [],
'artwork_file': null
}
};
@ -79,9 +72,7 @@ export default {
'link': {},
'description': {},
'language': { required },
'categories': { required }
},
files: {
'categories': { required },
'artwork_file': {}
}
},
@ -90,16 +81,16 @@ export default {
this.record = {
has_custom_art: false,
art: null,
links: {}
links: {
art: null
}
};
this.form = {
'title': '',
'link': '',
'description': '',
'language': 'en',
'categories': []
};
this.files = {
'categories': [],
'artwork_file': null
};
},
@ -110,51 +101,9 @@ export default {
'link': d.link,
'description': d.description,
'language': d.language,
'categories': d.categories
'categories': d.categories,
'artwork_file': null
};
},
buildSubmitRequest () {
let formData = new FormData();
formData.append('body', JSON.stringify(this.form));
Object.entries(this.files).forEach(([key, value]) => {
if (null !== value) {
formData.append(key, value);
}
});
return {
method: 'POST',
url: (this.isEditMode)
? this.editUrl
: this.createUrl,
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
};
},
clearArtwork (url) {
let buttonText = this.$gettext('Remove Artwork');
let buttonConfirmText = this.$gettext('Delete podcast artwork?');
Swal.fire({
title: buttonConfirmText,
confirmButtonText: buttonText,
confirmButtonColor: '#e64942',
showCancelButton: true,
focusCancel: true
}).then((result) => {
if (result.value) {
axios.delete(url).then((resp) => {
notify('<b>' + resp.data.message + '</b>', 'success');
this.$emit('relist');
this.close();
}).catch((err) => {
handleAxiosError(err);
});
}
});
}
}
};

View File

@ -5,9 +5,9 @@ namespace App\Controller\Api\Stations\Art;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
class PostArtAction
{
@ -26,20 +26,17 @@ class PostArtAction
->withJson(new Entity\Api\Error(404, __('Record not found.')));
}
$files = $request->getUploadedFiles();
if (!empty($files['art'])) {
$file = $files['art'];
/** @var UploadedFileInterface $file */
if ($file->getError() === UPLOAD_ERR_OK) {
$mediaRepo->updateAlbumArt($media, $file->getStream()->getContents());
$em->flush();
} elseif ($file->getError() !== UPLOAD_ERR_NO_FILE) {
return $response->withStatus(500)
->withJson(new Entity\Api\Error(500, $file->getError()));
}
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
$mediaRepo->updateAlbumArt(
$media,
$flowResponse->readAndDeleteUploadedFile()
);
$em->flush();
return $response->withJson(new Entity\Api\Status());
}
}

View File

@ -39,13 +39,13 @@ class FlowUploadAction
if (is_array($flowResponse)) {
$currentDir = $request->getParam('currentDirectory', '');
$destPath = $flowResponse['filename'];
$destPath = $flowResponse->originalFilename;
if (!empty($currentDir)) {
$destPath = $currentDir . '/' . $destPath;
}
try {
$stationMedia = $mediaRepo->getOrCreate($station, $destPath, $flowResponse['path']);
$stationMedia = $mediaRepo->getOrCreate($station, $destPath, $flowResponse->uploadedPath);
} catch (CannotProcessMediaException $e) {
$logger->error(
$e->getMessageWithPath(),
@ -78,7 +78,7 @@ class FlowUploadAction
}
}
$mediaStorage->addStorageUsed($flowResponse['size']);
$mediaStorage->addStorageUsed($flowResponse->size);
$em->persist($mediaStorage);
$em->flush();

View File

@ -10,10 +10,10 @@ use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow\UploadedFile;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@ -208,12 +208,34 @@ class PodcastEpisodesController extends AbstractApiCrudController
$station = $request->getStation();
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcast_id);
$parsedBody = $request->getParsedBody();
/** @var Entity\PodcastEpisode $record */
$record = $this->editRecord(
$request->getParsedBody(),
new Entity\PodcastEpisode($podcast)
);
$this->processFiles($request, $record);
if (!empty($parsedBody['artwork_file'])) {
$artwork = UploadedFile::fromArray($parsedBody['artwork_file'], $station->getRadioTempDir());
$this->episodeRepository->writeEpisodeArt(
$record,
$artwork->readAndDeleteUploadedFile()
);
$this->em->persist($record);
$this->em->flush();
}
if (!empty($parsedBody['media_file'])) {
$media = UploadedFile::fromArray($parsedBody['media_file'], $station->getRadioTempDir());
$this->podcastMediaRepository->upload(
$record,
$media->getOriginalFilename(),
$media->getUploadedPath()
);
}
return $response->withJson($this->viewRecord($record, $request));
}
@ -231,7 +253,6 @@ class PodcastEpisodesController extends AbstractApiCrudController
}
$this->editRecord($request->getParsedBody(), $podcast);
$this->processFiles($request, $podcast);
return $response->withJson(new Entity\Api\Status(true, __('Changes saved successfully.')));
}
@ -333,55 +354,13 @@ class PodcastEpisodesController extends AbstractApiCrudController
route_params: ['episode_id' => $record->getId()],
absolute: !$isInternal
);
$return->links['media'] = $router->fromHere(
route_name: 'api:stations:podcast:episode:media-internal',
route_params: ['episode_id' => $record->getId()],
absolute: !$isInternal
);
}
return $return;
}
protected function processFiles(
ServerRequest $request,
Entity\PodcastEpisode $record
): void {
$files = $request->getUploadedFiles();
$artwork = $files['artwork_file'] ?? null;
if ($artwork instanceof UploadedFileInterface && UPLOAD_ERR_OK === $artwork->getError()) {
$this->episodeRepository->writeEpisodeArt(
$record,
$artwork->getStream()->getContents()
);
$this->em->persist($record);
$this->em->flush();
}
$media = $files['media_file'] ?? null;
if ($media instanceof UploadedFileInterface && UPLOAD_ERR_OK === $media->getError()) {
$fsStations = new StationFilesystems($request->getStation());
$fsTemp = $fsStations->getTempFilesystem();
$originalName = basename($media->getClientFilename()) ?? ($record->getId() . '.mp3');
$originalExt = pathinfo($originalName, PATHINFO_EXTENSION);
$tempPath = $fsTemp->getLocalPath($record->getId() . '.' . $originalExt);
$media->moveTo($tempPath);
$artwork = $this->podcastMediaRepository->upload(
$record,
$originalName,
$tempPath,
$fsStations->getPodcastsFilesystem()
);
if (!empty($artwork) && 0 === $record->getArtUpdatedAt()) {
$this->episodeRepository->writeEpisodeArt(
$record,
$artwork
);
}
$this->em->persist($record);
$this->em->flush();
}
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Art;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow;
use Psr\Http\Message\ResponseInterface;
class PostArtAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastRepository $podcastRepo,
?string $podcast_id
): ResponseInterface {
$station = $request->getStation();
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
if (null !== $podcast_id) {
$podcast = $podcastRepo->fetchPodcastForStation($station, $podcast_id);
if (null === $podcast) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, __('Podcast not found!')));
}
$podcastRepo->writePodcastArt(
$podcast,
$flowResponse->readAndDeleteUploadedFile()
);
return $response->withJson(new Entity\Api\Status());
}
return $response->withJson($flowResponse);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes\Art;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow;
use Psr\Http\Message\ResponseInterface;
class PostArtAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
?string $episode_id
): ResponseInterface {
$station = $request->getStation();
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
if (null !== $episode_id) {
$episode = $episodeRepo->fetchEpisodeForStation($station, $episode_id);
if (null === $episode) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, __('Episode not found!')));
}
$episodeRepo->writeEpisodeArt(
$episode,
$flowResponse->readAndDeleteUploadedFile()
);
return $response->withJson(new Entity\Api\Status());
}
return $response->withJson($flowResponse);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class DeleteMediaAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastMediaRepository $mediaRepo,
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
string $episode_id
): ResponseInterface {
$station = $request->getStation();
$episode = $episodeRepo->fetchEpisodeForStation($station, $episode_id);
if (!($episode instanceof Entity\PodcastEpisode)) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, 'Media file not found.'));
}
$podcastMedia = $episode->getMedia();
if ($podcastMedia instanceof Entity\PodcastMedia) {
$mediaRepo->delete($podcastMedia);
}
return $response->withJson(new Entity\Api\Status());
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes;
namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
use App\Entity;
use App\Flysystem\StationFilesystems;
@ -10,7 +10,7 @@ use App\Http\Response;
use App\Http\ServerRequest;
use Psr\Http\Message\ResponseInterface;
class DownloadAction
class GetMediaAction
{
public function __invoke(
ServerRequest $request,

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api\Stations\Podcasts\Episodes\Media;
use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow;
use Psr\Http\Message\ResponseInterface;
class PostMediaAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\PodcastEpisodeRepository $episodeRepo,
Entity\Repository\PodcastMediaRepository $mediaRepo,
string $podcast_id,
?string $episode_id = null
): ResponseInterface {
$station = $request->getStation();
$flowResponse = Flow::process($request, $response, $station->getRadioTempDir());
if ($flowResponse instanceof ResponseInterface) {
return $flowResponse;
}
if (null !== $episode_id) {
$episode = $episodeRepo->fetchEpisodeForStation($station, $episode_id);
if (null === $episode) {
return $response->withStatus(404)
->withJson(new Entity\Api\Error(404, __('Episode not found!')));
}
$fsStation = new StationFilesystems($station);
$mediaRepo->upload(
$episode,
$flowResponse->getOriginalFilename(),
$flowResponse->getUploadedPath(),
$fsStation->getPodcastsFilesystem()
);
return $response->withJson(new Entity\Api\Status());
}
return $response->withJson($flowResponse);
}
}

View File

@ -10,11 +10,11 @@ use App\Entity;
use App\Flysystem\StationFilesystems;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Service\Flow\UploadedFile;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use OpenApi\Annotations as OA;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@ -163,12 +163,24 @@ class PodcastsController extends AbstractApiCrudController
{
$station = $request->getStation();
$parsedBody = $request->getParsedBody();
/** @var Entity\Podcast $record */
$record = $this->editRecord(
$request->getParsedBody(),
new Entity\Podcast($station->getPodcastsStorageLocation())
);
$this->processFiles($request, $record);
if (!empty($parsedBody['artwork_file'])) {
$artwork = UploadedFile::fromArray($parsedBody['artwork_file'], $station->getRadioTempDir());
$this->podcastRepository->writePodcastArt(
$record,
$artwork->readAndDeleteUploadedFile()
);
$this->em->persist($record);
$this->em->flush();
}
return $response->withJson($this->viewRecord($record, $request));
}
@ -186,7 +198,6 @@ class PodcastsController extends AbstractApiCrudController
}
$this->editRecord($request->getParsedBody(), $podcast);
$this->processFiles($request, $podcast);
return $response->withJson(new Entity\Api\Status(true, __('Changes saved successfully.')));
}
@ -287,6 +298,17 @@ class PodcastsController extends AbstractApiCrudController
route_params: ['podcast_id' => $record->getId()],
absolute: !$isInternal
);
$return->links['episode_new_art'] = $router->fromHere(
route_name: 'api:stations:podcast:episodes:new-art',
route_params: ['podcast_id' => $record->getId()],
absolute: !$isInternal
);
$return->links['episode_new_media'] = $router->fromHere(
route_name: 'api:stations:podcast:episodes:new-media',
route_params: ['podcast_id' => $record->getId()],
absolute: !$isInternal
);
}
return $return;
@ -325,22 +347,4 @@ class PodcastsController extends AbstractApiCrudController
)
);
}
protected function processFiles(
ServerRequest $request,
Entity\Podcast $record
): void {
$files = $request->getUploadedFiles();
$artwork = $files['artwork_file'] ?? null;
if ($artwork instanceof UploadedFileInterface && UPLOAD_ERR_OK === $artwork->getError()) {
$this->podcastRepository->writePodcastArt(
$record,
$artwork->getStream()->getContents()
);
$this->em->persist($record);
$this->em->flush();
}
}
}

View File

@ -25,8 +25,7 @@ class PodcastEpisodeRepository extends Repository
Serializer $serializer,
Environment $environment,
LoggerInterface $logger,
protected ImageManager $imageManager,
protected PodcastMediaRepository $podcastMediaRepo,
protected ImageManager $imageManager
) {
parent::__construct($entityManager, $serializer, $environment, $logger);
}
@ -121,8 +120,14 @@ class PodcastEpisodeRepository extends Repository
): void {
$fs ??= $episode->getPodcast()->getStorageLocation()->getFilesystem();
if (null !== $episode->getMedia()) {
$this->podcastMediaRepo->delete($episode->getMedia(), $fs);
$media = $episode->getMedia();
if (null !== $media) {
try {
$fs->delete($media->getPath());
} catch (UnableToDeleteFile) {
}
$this->em->remove($media);
}
$this->removeEpisodeArt($episode, $fs);

View File

@ -25,7 +25,8 @@ class PodcastMediaRepository extends Repository
Environment $environment,
LoggerInterface $logger,
protected GetId3MetadataService $metadataService,
protected ImageManager $imageManager
protected ImageManager $imageManager,
protected PodcastEpisodeRepository $episodeRepo,
) {
parent::__construct($em, $serializer, $environment, $logger);
}
@ -35,7 +36,7 @@ class PodcastMediaRepository extends Repository
string $originalPath,
string $uploadPath,
?ExtendedFilesystemInterface $fs = null
): ?string {
): void {
$podcast = $episode->getPodcast();
$storageLocation = $podcast->getStorageLocation();
@ -50,8 +51,9 @@ class PodcastMediaRepository extends Repository
);
}
if ($episode->getMedia() instanceof PodcastMedia) {
$this->delete($episode->getMedia(), $fs);
$existingMedia = $episode->getMedia();
if ($existingMedia instanceof PodcastMedia) {
$this->delete($existingMedia, $fs);
$episode->setMedia(null);
}
@ -73,10 +75,17 @@ class PodcastMediaRepository extends Repository
$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();
return $metadata->getArtwork();
}
public function delete(

View File

@ -0,0 +1,19 @@
<?php
namespace App\Exception;
use App\Exception;
use Psr\Log\LogLevel;
use Throwable;
class NoFileUploadedException extends Exception
{
public function __construct(
string $message = 'No file was uploaded.',
int $code = 0,
Throwable $previous = null,
string $loggerLevel = LogLevel::INFO
) {
parent::__construct($message, $code, $previous, $loggerLevel);
}
}

View File

@ -28,44 +28,45 @@ namespace App\Service;
use App\Exception;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Utilities\File;
use Normalizer;
use App\Service\Flow\UploadedFile;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UploadedFileInterface;
use RuntimeException;
use const PATHINFO_EXTENSION;
use const PATHINFO_FILENAME;
use const SCANDIR_SORT_NONE;
class Flow
{
/**
* Process the request and return a response if necessary, or the completed file details if successful.
*
* @param ServerRequest $request
* @param Response $response
* @param string|null $temp_dir
*
* @return mixed[]|ResponseInterface|null
*/
public static function process(
ServerRequest $request,
Response $response,
string $temp_dir = null
): array|ResponseInterface|null {
if (null === $temp_dir) {
$temp_dir = sys_get_temp_dir() . '/uploads/';
string $tempDir = null
): UploadedFile|ResponseInterface {
if (null === $tempDir) {
$tempDir = sys_get_temp_dir() . '/uploads';
}
$params = $request->getParams();
$flowIdentifier = $params['flowIdentifier'] ?? '';
$flowChunkNumber = (int)($params['flowChunkNumber'] ?? 1);
$flowFilename = $params['flowFilename'] ?? ($flowIdentifier ?: 'upload-' . date('Ymd'));
// Handle a regular file upload that isn't using flow.
if (empty($flowIdentifier) || 1 === $flowChunkNumber) {
if ('GET' === $request->getMethod()) {
return $response->withStatus(200, 'OK');
}
return self::handleStandardUpload($request, $tempDir);
}
$flowFilename = $params['flowFilename'] ?? ($flowIdentifier ?: ('upload-' . date('Ymd')));
// init the destination file (format <filename.ext>.part<#chunk>
$chunkBaseDir = $temp_dir . '/' . $flowIdentifier;
$chunkBaseDir = $tempDir . '/' . $flowIdentifier;
$chunkPath = $chunkBaseDir . '/' . $flowIdentifier . '.part' . $flowChunkNumber;
$currentChunkSize = (int)($params['flowCurrentChunkSize'] ?? 0);
@ -88,47 +89,67 @@ class Flow
}
$files = $request->getUploadedFiles();
if (!empty($files)) {
foreach ($files as $file) {
/** @var UploadedFileInterface $file */
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new Exception('Error ' . $file->getError() . ' in file ' . $flowFilename);
}
// the file is stored in a temporary directory
if (!is_dir($chunkBaseDir) && !mkdir($chunkBaseDir, 0777, true) && !is_dir($chunkBaseDir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $chunkBaseDir));
}
if ($file->getSize() !== $currentChunkSize) {
throw new Exception(
sprintf(
'File size of %s does not match expected size of %s',
$file->getSize(),
$currentChunkSize
)
);
}
$file->moveTo($chunkPath);
}
if (self::allPartsExist($chunkBaseDir, $targetSize, $targetChunks)) {
return self::createFileFromChunks(
$temp_dir,
$chunkBaseDir,
$flowIdentifier,
$flowFilename,
$targetChunks
);
}
// Return an OK status to indicate that the chunk upload itself succeeded.
return $response->withStatus(200, 'OK');
if (empty($files)) {
throw new Exception\NoFileUploadedException();
}
return null;
/** @var UploadedFileInterface $file */
$file = reset($files);
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new RuntimeException('Error ' . $file->getError() . ' in file ' . $flowFilename);
}
// the file is stored in a temporary directory
if (!is_dir($chunkBaseDir) && !mkdir($chunkBaseDir, 0777, true) && !is_dir($chunkBaseDir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $chunkBaseDir));
}
if ($file->getSize() !== $currentChunkSize) {
throw new RuntimeException(
sprintf(
'File size of %s does not match expected size of %s',
$file->getSize(),
$currentChunkSize
)
);
}
$file->moveTo($chunkPath);
if (self::allPartsExist($chunkBaseDir, $targetSize, $targetChunks)) {
return self::createFileFromChunks(
$tempDir,
$chunkBaseDir,
$flowIdentifier,
$flowFilename,
$targetChunks
);
}
// Return an OK status to indicate that the chunk upload itself succeeded.
return $response->withStatus(200, 'OK');
}
protected static function handleStandardUpload(
ServerRequest $request,
string $tempDir
): UploadedFile {
$files = $request->getUploadedFiles();
if (empty($files)) {
throw new Exception\NoFileUploadedException();
}
/** @var UploadedFileInterface $file */
$file = reset($files);
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new RuntimeException('Uploaded file error code: ' . $file->getError());
}
$uploadedFile = new UploadedFile($file->getClientFilename(), null, $tempDir);
$file->moveTo($uploadedFile->getUploadedPath());
return $uploadedFile;
}
/**
@ -154,38 +175,16 @@ class Flow
return ($chunkSize === $targetSize && $chunkNumber === $targetChunkNumber);
}
/**
* Reassemble the file on the local destination disk and return the relevant information.
*
* @param string $tempDir
* @param string $chunkBaseDir
* @param string $chunkIdentifier
* @param string $originalFileName
* @param int $numChunks
*
* @return mixed[]
*/
protected static function createFileFromChunks(
string $tempDir,
string $chunkBaseDir,
string $chunkIdentifier,
string $originalFileName,
int $numChunks
): array {
$originalFileName = basename($originalFileName);
$originalFileName = Normalizer::normalize($originalFileName, Normalizer::FORM_KD);
$originalFileName = File::sanitizeFileName($originalFileName);
// Truncate filenames whose lengths are longer than 255 characters, while preserving extension.
if (strlen($originalFileName) > 255) {
$ext = pathinfo($originalFileName, PATHINFO_EXTENSION);
$fileName = pathinfo($originalFileName, PATHINFO_FILENAME);
$fileName = substr($fileName, 0, 255 - 1 - strlen($ext));
$originalFileName = $fileName . '.' . $ext;
}
$finalPath = $tempDir . '/' . $originalFileName;
): UploadedFile {
$uploadedFile = new UploadedFile($originalFileName, null, $tempDir);
$finalPath = $uploadedFile->getUploadedPath();
$fp = fopen($finalPath, 'wb+');
for ($i = 1; $i <= $numChunks; $i++) {
@ -202,11 +201,7 @@ class Flow
self::rrmdir($chunkBaseDir);
}
return [
'path' => $finalPath,
'filename' => $originalFileName,
'size' => filesize($finalPath),
];
return $uploadedFile;
}
/**

View File

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Service\Flow;
use App\Utilities\File;
final class UploadedFile implements \JsonSerializable
{
protected string $originalFilename;
protected string $uploadedPath;
public function __construct(
?string $originalFilename,
?string $uploadedPath,
?string $tempDir
) {
$tempDir ??= sys_get_temp_dir();
$originalFilename ??= tempnam($tempDir, 'upload');
$this->originalFilename = self::filterOriginalFilename($originalFilename);
if (null === $uploadedPath) {
$prefix = substr(bin2hex(random_bytes(5)), 0, 9);
$this->uploadedPath = $tempDir . '/' . $prefix . '_' . $originalFilename;
} else {
$uploadedPath = realpath($uploadedPath);
if (!str_starts_with($uploadedPath, $tempDir)) {
throw new \InvalidArgumentException('Uploaded path is not inside specified temporary directory.');
}
if (!is_file($uploadedPath)) {
throw new \InvalidArgumentException(sprintf('File does not exist at path: %s', $uploadedPath));
}
$this->uploadedPath = $uploadedPath;
}
}
public function getOriginalFilename(): string
{
return $this->originalFilename;
}
public function getUploadedPath(): string
{
return $this->uploadedPath;
}
public function getUploadedSize(): int
{
return filesize($this->uploadedPath);
}
public function readAndDeleteUploadedFile(): string
{
$contents = file_get_contents($this->uploadedPath);
@unlink($this->uploadedPath);
return $contents;
}
/** @return mixed[] */
public function jsonSerialize(): array
{
return [
'originalFilename' => $this->originalFilename,
'uploadedPath' => $this->uploadedPath,
];
}
public static function fromArray(array $input, string $tempDir): self
{
if (!isset($input['originalFilename'], $input['uploadedPath'])) {
throw new \InvalidArgumentException('Uploaded file array is malformed.');
}
return new self($input['originalFilename'], $input['uploadedPath'], $tempDir);
}
public static function filterOriginalFilename(string $name): string
{
$name = basename($name);
$normalizedName = \Normalizer::normalize($name, \Normalizer::FORM_KD);
if (false !== $normalizedName) {
$name = $normalizedName;
}
$name = File::sanitizeFileName($name);
// Truncate filenames whose lengths are longer than 255 characters, while preserving extension.
$thresholdLength = 255 - 10; // To allow for a prefix.
if (strlen($name) > $thresholdLength) {
$fileExt = pathinfo($name, PATHINFO_EXTENSION);
$fileName = pathinfo($name, PATHINFO_FILENAME);
$fileName = substr($fileName, 0, $thresholdLength - 1 - strlen($fileExt));
$name = $fileName . '.' . $fileExt;
}
return $name;
}
}

View File

@ -6,6 +6,7 @@ $this->layout('main', [
$props = [
'listUrl' => $router->fromHere('api:stations:podcasts'),
'newArtUrl' => $router->fromHere('api:stations:podcasts:new-art'),
'stationUrl' => $router->fromHere('stations:index:index', [$stationId]),
'locale' => substr($customization->getLocale(), 0, 2),
'stationTimeZone' => $stationTz,