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
|
## 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.
|
- You can now embed the "Schedule" panel from the station's profile into your own web page as an embeddabl component.
|
||||||
|
|
||||||
- Mount point updates:
|
- Mount point updates:
|
||||||
|
|
|
@ -503,6 +503,11 @@ return function (App $app) {
|
||||||
Controller\Api\Stations\Playlists\DeleteQueueAction::class
|
Controller\Api\Stations\Playlists\DeleteQueueAction::class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$group->post(
|
||||||
|
'/clone',
|
||||||
|
Controller\Api\Stations\Playlists\CloneAction::class
|
||||||
|
)->setName('api:stations:playlist:clone');
|
||||||
|
|
||||||
$group->post(
|
$group->post(
|
||||||
'/import',
|
'/import',
|
||||||
Controller\Api\Stations\Playlists\ImportAction::class
|
Controller\Api\Stations\Playlists\ImportAction::class
|
||||||
|
|
|
@ -51,6 +51,9 @@
|
||||||
v-if="row.item.order === 'shuffle'">
|
v-if="row.item.order === 'shuffle'">
|
||||||
{{ langReshuffleButton }}
|
{{ langReshuffleButton }}
|
||||||
</b-dropdown-item>
|
</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']">
|
<template v-for="format in ['pls', 'm3u']">
|
||||||
<b-dropdown-item :href="row.item.links.export[format]" target="_blank">
|
<b-dropdown-item :href="row.item.links.export[format]" target="_blank">
|
||||||
<translate :key="'lang_format_'+format" :translate-params="{ format: format.toUpperCase() }">
|
<translate :key="'lang_format_'+format" :translate-params="{ format: format.toUpperCase() }">
|
||||||
|
@ -117,6 +120,7 @@
|
||||||
<queue-modal ref="queueModal"></queue-modal>
|
<queue-modal ref="queueModal"></queue-modal>
|
||||||
<reorder-modal ref="reorderModal"></reorder-modal>
|
<reorder-modal ref="reorderModal"></reorder-modal>
|
||||||
<import-modal ref="importModal" @relist="relist"></import-modal>
|
<import-modal ref="importModal" @relist="relist"></import-modal>
|
||||||
|
<clone-modal ref="cloneModal" @relist="relist"></clone-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -130,10 +134,11 @@ import QueueModal from './Playlists/QueueModal';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Icon from '../Common/Icon';
|
import Icon from '../Common/Icon';
|
||||||
import handleAxiosError from '../Function/handleAxiosError';
|
import handleAxiosError from '../Function/handleAxiosError';
|
||||||
|
import CloneModal from './Playlists/CloneModal';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'StationPlaylists',
|
name: 'StationPlaylists',
|
||||||
components: { Icon, QueueModal, ImportModal, ReorderModal, EditModal, Schedule, DataTable },
|
components: { CloneModal, Icon, QueueModal, ImportModal, ReorderModal, EditModal, Schedule, DataTable },
|
||||||
props: {
|
props: {
|
||||||
listUrl: String,
|
listUrl: String,
|
||||||
scheduleUrl: String,
|
scheduleUrl: String,
|
||||||
|
@ -171,6 +176,9 @@ export default {
|
||||||
langReshuffleButton () {
|
langReshuffleButton () {
|
||||||
return this.$gettext('Reshuffle');
|
return this.$gettext('Reshuffle');
|
||||||
},
|
},
|
||||||
|
langCloneButton () {
|
||||||
|
return this.$gettext('Duplicate');
|
||||||
|
},
|
||||||
langImportButton () {
|
langImportButton () {
|
||||||
return this.$gettext('Import from PLS/M3U');
|
return this.$gettext('Import from PLS/M3U');
|
||||||
}
|
}
|
||||||
|
@ -244,6 +252,9 @@ export default {
|
||||||
doImport (url) {
|
doImport (url) {
|
||||||
this.$refs.importModal.open(url);
|
this.$refs.importModal.open(url);
|
||||||
},
|
},
|
||||||
|
doClone (name, url) {
|
||||||
|
this.$refs.cloneModal.open(name, url);
|
||||||
|
},
|
||||||
doModify (url) {
|
doModify (url) {
|
||||||
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
|
notify('<b>' + this.$gettext('Applying changes...') + '</b>', 'warning', {
|
||||||
delay: 3000
|
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),
|
'toggle' => $router->fromHere('api:stations:playlist:toggle', ['id' => $record->getId()], [], !$isInternal),
|
||||||
'order' => $router->fromHere('api:stations:playlist:order', ['id' => $record->getId()], [], !$isInternal),
|
'order' => $router->fromHere('api:stations:playlist:order', ['id' => $record->getId()], [], !$isInternal),
|
||||||
'reshuffle' => $router->fromHere(
|
'reshuffle' => $router->fromHere(
|
||||||
'api:stations:playlist:reshuffle',
|
route_name: 'api:stations:playlist:reshuffle',
|
||||||
['id' => $record->getId()],
|
route_params: ['id' => $record->getId()],
|
||||||
[],
|
absolute: !$isInternal
|
||||||
!$isInternal
|
|
||||||
),
|
),
|
||||||
'queue' => $router->fromHere(
|
'queue' => $router->fromHere(
|
||||||
'api:stations:playlist:queue',
|
route_name: 'api:stations:playlist:queue',
|
||||||
['id' => $record->getId()],
|
route_params: ['id' => $record->getId()],
|
||||||
[],
|
absolute: !$isInternal
|
||||||
!$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),
|
'self' => $router->fromHere($this->resourceRouteName, ['id' => $record->getId()], [], !$isInternal),
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach (['pls', 'm3u'] as $format) {
|
foreach (['pls', 'm3u'] as $format) {
|
||||||
$return['links']['export'][$format] = $router->fromHere(
|
$return['links']['export'][$format] = $router->fromHere(
|
||||||
'api:stations:playlist:export',
|
route_name: 'api:stations:playlist:export',
|
||||||
['id' => $record->getId(), 'format' => $format],
|
route_params: ['id' => $record->getId(), 'format' => $format],
|
||||||
[],
|
absolute: !$isInternal
|
||||||
!$isInternal
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -499,6 +499,12 @@ class StationPlaylist implements Stringable, Interfaces\StationCloneAwareInterfa
|
||||||
$this->play_per_minutes = $play_per_minutes;
|
$this->play_per_minutes = $play_per_minutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function __clone()
|
||||||
|
{
|
||||||
|
$this->played_at = 0;
|
||||||
|
$this->queue_reset_at = 0;
|
||||||
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
{
|
{
|
||||||
return $this->getStation() . ' Playlist: ' . $this->getName();
|
return $this->getStation() . ' Playlist: ' . $this->getName();
|
||||||
|
|
Loading…
Reference in New Issue