Improve Flow upload handling and podcast media/artwork management.
This commit is contained in:
parent
133a94380d
commit
913d2dfad2
|
@ -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
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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,
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue