Add ability to duplicate ("clone") playlists.
This commit is contained in:
parent
b1d358c924
commit
64b7d83258
|
@ -5,6 +5,9 @@ release channel, you can take advantage of these new features and fixes.
|
|||
|
||||
## New Features/Changes
|
||||
|
||||
- You can now duplicate a single playlist within a station, choosing whether to copy over the schedule entries or media
|
||||
associations that the current playlist has.
|
||||
|
||||
- You can now embed the "Schedule" panel from the station's profile into your own web page as an embeddabl component.
|
||||
|
||||
- Mount point updates:
|
||||
|
|
|
@ -503,6 +503,11 @@ return function (App $app) {
|
|||
Controller\Api\Stations\Playlists\DeleteQueueAction::class
|
||||
);
|
||||
|
||||
$group->post(
|
||||
'/clone',
|
||||
Controller\Api\Stations\Playlists\CloneAction::class
|
||||
)->setName('api:stations:playlist:clone');
|
||||
|
||||
$group->post(
|
||||
'/import',
|
||||
Controller\Api\Stations\Playlists\ImportAction::class
|
||||
|
|
|
@ -51,6 +51,9 @@
|
|||
v-if="row.item.order === 'shuffle'">
|
||||
{{ langReshuffleButton }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item @click.prevent="doClone(row.item.name, row.item.links.clone)">
|
||||
{{ langCloneButton }}
|
||||
</b-dropdown-item>
|
||||
<template v-for="format in ['pls', 'm3u']">
|
||||
<b-dropdown-item :href="row.item.links.export[format]" target="_blank">
|
||||
<translate :key="'lang_format_'+format" :translate-params="{ format: format.toUpperCase() }">
|
||||
|
@ -117,6 +120,7 @@
|
|||
<queue-modal ref="queueModal"></queue-modal>
|
||||
<reorder-modal ref="reorderModal"></reorder-modal>
|
||||
<import-modal ref="importModal" @relist="relist"></import-modal>
|
||||
<clone-modal ref="cloneModal" @relist="relist"></clone-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -130,10 +134,11 @@ import QueueModal from './Playlists/QueueModal';
|
|||
import axios from 'axios';
|
||||
import Icon from '../Common/Icon';
|
||||
import handleAxiosError from '../Function/handleAxiosError';
|
||||
import CloneModal from './Playlists/CloneModal';
|
||||
|
||||
export default {
|
||||
name: 'StationPlaylists',
|
||||
components: { Icon, QueueModal, ImportModal, ReorderModal, EditModal, Schedule, DataTable },
|
||||
components: { CloneModal, Icon, QueueModal, ImportModal, ReorderModal, EditModal, Schedule, DataTable },
|
||||
props: {
|
||||
listUrl: String,
|
||||
scheduleUrl: String,
|
||||
|
@ -171,6 +176,9 @@ export default {
|
|||
langReshuffleButton () {
|
||||
return this.$gettext('Reshuffle');
|
||||
},
|
||||
langCloneButton () {
|
||||
return this.$gettext('Duplicate');
|
||||
},
|
||||
langImportButton () {
|
||||
return this.$gettext('Import from PLS/M3U');
|
||||
}
|
||||
|
@ -244,6 +252,9 @@ export default {
|
|||
doImport (url) {
|
||||
this.$refs.importModal.open(url);
|
||||
},
|
||||
doClone (name, url) {
|
||||
this.$refs.cloneModal.open(name, url);
|
||||
},
|
||||
doModify (url) {
|
||||
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
|
||||
delay: 3000
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<b-modal size="md" id="clone_modal" ref="modal" :title="langTitle">
|
||||
<b-alert variant="danger" :show="error != null">{{ error }}</b-alert>
|
||||
|
||||
<b-form class="form" @submit.prevent="doSubmit">
|
||||
<b-row>
|
||||
<b-form-group class="col-md-12" label-for="form_edit_name">
|
||||
<template #label>
|
||||
<translate key="lang_form_edit_name">New Playlist Name</translate>
|
||||
</template>
|
||||
<b-form-input id="form_edit_name" type="text" v-model="$v.form.name.$model"
|
||||
:state="$v.form.name.$dirty ? !$v.form.name.$error : null"></b-form-input>
|
||||
<b-form-invalid-feedback>
|
||||
<translate key="lang_error_required">This field is required.</translate>
|
||||
</b-form-invalid-feedback>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group class="col-md-12" label-for="edit_form_clone">
|
||||
<template #label>
|
||||
<translate key="lang_form_clone">Customize Copy</translate>
|
||||
</template>
|
||||
|
||||
<b-form-checkbox-group stacked id="edit_form_clone" v-model="$v.form.clone.$model">
|
||||
<b-form-checkbox value="media">
|
||||
<translate key="lang_clone_media">Copy associated media and folders.</translate>
|
||||
</b-form-checkbox>
|
||||
<b-form-checkbox value="schedule">
|
||||
<translate key="lang_clone_schedule">Copy scheduled playback times.</translate>
|
||||
</b-form-checkbox>
|
||||
</b-form-checkbox-group>
|
||||
</b-form-group>
|
||||
</b-row>
|
||||
|
||||
<invisible-submit-button/>
|
||||
</b-form>
|
||||
<template v-slot:modal-footer>
|
||||
<b-button variant="default" type="button" @click="close">
|
||||
<translate key="lang_btn_close">Close</translate>
|
||||
</b-button>
|
||||
<b-button variant="primary" type="submit" @click="doSubmit" :disabled="$v.form.$invalid">
|
||||
<translate key="lang_btn_save_changes">Save Changes</translate>
|
||||
</b-button>
|
||||
</template>
|
||||
</b-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import required from 'vuelidate/src/validators/required';
|
||||
import InvisibleSubmitButton from '../../Common/InvisibleSubmitButton';
|
||||
import axios from 'axios';
|
||||
import handleAxiosError from '../../Function/handleAxiosError';
|
||||
import { validationMixin } from 'vuelidate';
|
||||
|
||||
export default {
|
||||
name: 'CloneModal',
|
||||
components: { InvisibleSubmitButton },
|
||||
emits: ['relist'],
|
||||
mixins: [
|
||||
validationMixin
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
error: null,
|
||||
cloneUrl: null,
|
||||
form: {}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
langTitle () {
|
||||
return this.$gettext('Duplicate Playlist');
|
||||
}
|
||||
},
|
||||
validations: {
|
||||
form: {
|
||||
'name': { required },
|
||||
'clone': {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetForm () {
|
||||
this.form = {
|
||||
'name': '',
|
||||
'clone': []
|
||||
};
|
||||
},
|
||||
open (name, cloneUrl) {
|
||||
this.error = null;
|
||||
|
||||
this.resetForm();
|
||||
this.cloneUrl = cloneUrl;
|
||||
|
||||
let langNewName = this.$gettext('%{name} - Copy');
|
||||
this.form.name = this.$gettextInterpolate(langNewName, { name: name });
|
||||
|
||||
this.$refs.modal.show();
|
||||
},
|
||||
doSubmit () {
|
||||
this.$v.form.$touch();
|
||||
if (this.$v.form.$anyError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.error = null;
|
||||
|
||||
axios({
|
||||
method: 'POST',
|
||||
url: this.cloneUrl,
|
||||
data: this.form
|
||||
}).then((resp) => {
|
||||
let notifyMessage = this.$gettext('Changes saved.');
|
||||
notify('<b>' + notifyMessage + '</b>', 'success');
|
||||
|
||||
this.$emit('relist');
|
||||
this.close();
|
||||
}).catch((error) => {
|
||||
let notifyMessage = this.$gettext('An error occurred and your request could not be completed.');
|
||||
notifyMessage = handleAxiosError(error, notifyMessage);
|
||||
|
||||
this.error = notifyMessage;
|
||||
});
|
||||
},
|
||||
close () {
|
||||
this.error = null;
|
||||
this.cloneUrl = null;
|
||||
this.resetForm();
|
||||
|
||||
this.$v.form.$reset();
|
||||
this.$refs.modal.hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Controller\Api\Stations\Playlists;
|
||||
|
||||
use App\Entity;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use DeepCopy;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class CloneAction extends AbstractPlaylistsAction
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
$id
|
||||
): ResponseInterface {
|
||||
$record = $this->requireRecord($request->getStation(), $id);
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$copier = new DeepCopy\DeepCopy();
|
||||
$copier->addFilter(
|
||||
new DeepCopy\Filter\Doctrine\DoctrineProxyFilter(),
|
||||
new DeepCopy\Matcher\Doctrine\DoctrineProxyMatcher()
|
||||
);
|
||||
$copier->addFilter(
|
||||
new DeepCopy\Filter\SetNullFilter(),
|
||||
new DeepCopy\Matcher\PropertyNameMatcher('id')
|
||||
);
|
||||
$copier->addFilter(
|
||||
new DeepCopy\Filter\Doctrine\DoctrineEmptyCollectionFilter(),
|
||||
new DeepCopy\Matcher\PropertyTypeMatcher(Collection::class)
|
||||
);
|
||||
|
||||
$copier->addFilter(
|
||||
new DeepCopy\Filter\KeepFilter(),
|
||||
new DeepCopy\Matcher\PropertyNameMatcher('station')
|
||||
);
|
||||
$copier->addFilter(
|
||||
new DeepCopy\Filter\KeepFilter(),
|
||||
new DeepCopy\Matcher\PropertyMatcher(Entity\StationPlaylistMedia::class, 'media')
|
||||
);
|
||||
|
||||
/** @var Entity\StationPlaylist $newRecord */
|
||||
$newRecord = $copier->copy($record);
|
||||
|
||||
$newRecord->setName($data['name'] ?? ($record->getName() . ' - Copy'));
|
||||
|
||||
$this->em->persist($newRecord);
|
||||
|
||||
$toClone = $data['clone'] ?? [];
|
||||
|
||||
if (in_array('schedule', $toClone, true)) {
|
||||
foreach ($record->getScheduleItems() as $oldScheduleItem) {
|
||||
/** @var Entity\StationSchedule $newScheduleItem */
|
||||
$newScheduleItem = $copier->copy($oldScheduleItem);
|
||||
$newScheduleItem->setPlaylist($newRecord);
|
||||
|
||||
$this->em->persist($newScheduleItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array('media', $toClone, true)) {
|
||||
foreach ($record->getFolders() as $oldPlaylistFolder) {
|
||||
/** @var Entity\StationPlaylistFolder $newPlaylistFolder */
|
||||
$newPlaylistFolder = $copier->copy($oldPlaylistFolder);
|
||||
$newPlaylistFolder->setPlaylist($newRecord);
|
||||
$this->em->persist($newPlaylistFolder);
|
||||
}
|
||||
|
||||
foreach ($record->getMediaItems() as $oldMediaItem) {
|
||||
/** @var Entity\StationPlaylistMedia $newMediaItem */
|
||||
$newMediaItem = $copier->copy($oldMediaItem);
|
||||
|
||||
$newMediaItem->setPlaylist($newRecord);
|
||||
$this->em->persist($newMediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $response->withJson(new Entity\Api\Status());
|
||||
}
|
||||
}
|
|
@ -204,27 +204,33 @@ class PlaylistsController extends AbstractScheduledEntityController
|
|||
'toggle' => $router->fromHere('api:stations:playlist:toggle', ['id' => $record->getId()], [], !$isInternal),
|
||||
'order' => $router->fromHere('api:stations:playlist:order', ['id' => $record->getId()], [], !$isInternal),
|
||||
'reshuffle' => $router->fromHere(
|
||||
'api:stations:playlist:reshuffle',
|
||||
['id' => $record->getId()],
|
||||
[],
|
||||
!$isInternal
|
||||
route_name: 'api:stations:playlist:reshuffle',
|
||||
route_params: ['id' => $record->getId()],
|
||||
absolute: !$isInternal
|
||||
),
|
||||
'queue' => $router->fromHere(
|
||||
'api:stations:playlist:queue',
|
||||
['id' => $record->getId()],
|
||||
[],
|
||||
!$isInternal
|
||||
route_name: 'api:stations:playlist:queue',
|
||||
route_params: ['id' => $record->getId()],
|
||||
absolute: !$isInternal
|
||||
),
|
||||
'import' => $router->fromHere(
|
||||
route_name: 'api:stations:playlist:import',
|
||||
route_params: ['id' => $record->getId()],
|
||||
absolute: !$isInternal
|
||||
),
|
||||
'clone' => $router->fromHere(
|
||||
route_name: 'api:stations:playlist:clone',
|
||||
route_params: ['id' => $record->getId()],
|
||||
absolute: !$isInternal
|
||||
),
|
||||
'import' => $router->fromHere('api:stations:playlist:import', ['id' => $record->getId()], [], !$isInternal),
|
||||
'self' => $router->fromHere($this->resourceRouteName, ['id' => $record->getId()], [], !$isInternal),
|
||||
];
|
||||
|
||||
foreach (['pls', 'm3u'] as $format) {
|
||||
$return['links']['export'][$format] = $router->fromHere(
|
||||
'api:stations:playlist:export',
|
||||
['id' => $record->getId(), 'format' => $format],
|
||||
[],
|
||||
!$isInternal
|
||||
route_name: 'api:stations:playlist:export',
|
||||
route_params: ['id' => $record->getId(), 'format' => $format],
|
||||
absolute: !$isInternal
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -499,6 +499,12 @@ class StationPlaylist implements Stringable, Interfaces\StationCloneAwareInterfa
|
|||
$this->play_per_minutes = $play_per_minutes;
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
$this->played_at = 0;
|
||||
$this->queue_reset_at = 0;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getStation() . ' Playlist: ' . $this->getName();
|
||||
|
|
Loading…
Reference in New Issue