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 }}
+
@@ -117,6 +120,7 @@
+
@@ -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 @@
+
+
+ {{ error }}
+
+
+
+
+
+ New Playlist Name
+
+
+
+ This field is required.
+
+
+
+
+
+ Customize Copy
+
+
+
+
+ Copy associated media and folders.
+
+
+ Copy scheduled playback times.
+
+
+
+
+
+
+
+
+
+ Close
+
+
+ Save Changes
+
+
+
+
+
+
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();