diff --git a/CHANGELOG.md b/CHANGELOG.md index 458443ba3..810851d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/config/routes/api.php b/config/routes/api.php index bdcb5b9b6..131d377d6 100644 --- a/config/routes/api.php +++ b/config/routes/api.php @@ -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 diff --git a/frontend/vue/Stations/Playlists.vue b/frontend/vue/Stations/Playlists.vue index c72dd88d0..0c774d162 100644 --- a/frontend/vue/Stations/Playlists.vue +++ b/frontend/vue/Stations/Playlists.vue @@ -51,6 +51,9 @@ v-if="row.item.order === 'shuffle'"> {{ langReshuffleButton }} + + {{ langCloneButton }} + @@ -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('' + this.$gettext('Applying changes...') + '', 'warning', { delay: 3000 diff --git a/frontend/vue/Stations/Playlists/CloneModal.vue b/frontend/vue/Stations/Playlists/CloneModal.vue new file mode 100644 index 000000000..0bfbfce32 --- /dev/null +++ b/frontend/vue/Stations/Playlists/CloneModal.vue @@ -0,0 +1,132 @@ + + + diff --git a/src/Controller/Api/Stations/Playlists/CloneAction.php b/src/Controller/Api/Stations/Playlists/CloneAction.php new file mode 100644 index 000000000..838a91189 --- /dev/null +++ b/src/Controller/Api/Stations/Playlists/CloneAction.php @@ -0,0 +1,86 @@ +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()); + } +} diff --git a/src/Controller/Api/Stations/PlaylistsController.php b/src/Controller/Api/Stations/PlaylistsController.php index 0ee18d384..faa626cab 100644 --- a/src/Controller/Api/Stations/PlaylistsController.php +++ b/src/Controller/Api/Stations/PlaylistsController.php @@ -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 ); } diff --git a/src/Entity/StationPlaylist.php b/src/Entity/StationPlaylist.php index 50bfe9259..acda9ae60 100644 --- a/src/Entity/StationPlaylist.php +++ b/src/Entity/StationPlaylist.php @@ -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();