Add ability to duplicate ("clone") playlists.

This commit is contained in:
Buster "Silver Eagle" Neece 2021-07-14 19:17:22 -05:00
parent b1d358c924
commit 64b7d83258
No known key found for this signature in database
GPG Key ID: 6D9E12FF03411F4E
7 changed files with 263 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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