From 4f6f84bdb54380ad03194e5dfaff38dce4ad007b Mon Sep 17 00:00:00 2001
From: Dominik
Date: Sun, 9 Aug 2026 22:11:21 +0200
Subject: [PATCH] feat: add playlist song picker
---
frontend/src/stores/music.test.ts | 53 +++++
frontend/src/stores/phone.ts | 8 +
frontend/src/views/apps/MusicApp.vue | 318 ++++++++++++++++++++++++---
frontend/testserver/index.cjs | 13 +-
sky_phone/config/locales/en.lua | 9 +-
sky_phone/source/server/music.lua | 75 +++++--
6 files changed, 416 insertions(+), 60 deletions(-)
diff --git a/frontend/src/stores/music.test.ts b/frontend/src/stores/music.test.ts
index 5b18293..d56673f 100644
--- a/frontend/src/stores/music.test.ts
+++ b/frontend/src/stores/music.test.ts
@@ -10,6 +10,9 @@ import {
} from 'vitest'
import type { MusicTrack } from '@/types/music'
+import { nuiCall } from '@/utils/nui'
+
+vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
class FakeAudio extends EventTarget {
static latest: FakeAudio | null = null
@@ -117,6 +120,7 @@ beforeEach(() => {
createObjectUrlMock.mockReset()
createObjectUrlMock.mockReturnValue('blob:sky-music')
revokeObjectUrlMock.mockReset()
+ vi.mocked(nuiCall).mockReset()
})
afterEach(() => {
@@ -198,3 +202,52 @@ describe('music server playback', () => {
expect(createObjectUrlMock).toHaveBeenCalledOnce()
})
})
+
+describe('music playlists', () => {
+ it('adds a track with the expected payload and applies the refreshed playlist', async () => {
+ const serverTrack = track('night-drive')
+ vi.mocked(nuiCall).mockResolvedValueOnce({
+ data: {
+ playlists: [
+ {
+ createdAt: 1,
+ entries: [{ songId: serverTrack.id, source: serverTrack.source }],
+ id: 'playlist-1',
+ name: 'Night Ride',
+ },
+ ],
+ serverTracks: [serverTrack],
+ youtubeTracks: [],
+ },
+ success: true,
+ })
+ const music = useMusicStore()
+
+ const success = await music.addToPlaylist('playlist-1', serverTrack)
+
+ expect(success).toBe(true)
+ expect(nuiCall).toHaveBeenCalledWith('music:add-to-playlist', {
+ playlistId: 'playlist-1',
+ songId: 'night-drive',
+ source: 'server',
+ })
+ expect(music.playlists[0]?.entries).toEqual([
+ { songId: 'night-drive', source: 'server' },
+ ])
+ })
+
+ it('surfaces a duplicate-track error and releases the loading state', async () => {
+ const serverTrack = track('night-drive')
+ vi.mocked(nuiCall).mockResolvedValueOnce({
+ error: 'song_already_in_playlist',
+ success: false,
+ })
+ const music = useMusicStore()
+
+ const success = await music.addToPlaylist('playlist-1', serverTrack)
+
+ expect(success).toBe(false)
+ expect(music.error).toBe('song_already_in_playlist')
+ expect(music.isLoading).toBe(false)
+ })
+})
diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts
index b4a4927..18208b1 100644
--- a/frontend/src/stores/phone.ts
+++ b/frontend/src/stores/phone.ts
@@ -1866,10 +1866,16 @@ const defaultLocales: LocaleTree = {
songRemoved: 'Song removed from your library.',
newPlaylist: 'New Playlist',
renamePlaylist: 'Rename Playlist',
+ addSongs: 'Add Songs',
+ addSongsBody: 'Choose songs from your library for this playlist.',
+ allSongsAdded: 'All Songs Added',
+ allSongsAddedBody:
+ 'Every song in your library is already in this playlist.',
playlistBody: 'Give this playlist a name you will recognize.',
playlistName: 'Playlist Name',
playlistPlaceholder: 'My Playlist',
playlistCreated: 'Playlist created.',
+ playlistCreatedWithSong: 'Playlist created and song added.',
playlistRenamed: 'Playlist renamed.',
playlistDeleted: 'Playlist deleted.',
playlistActions: 'Playlist actions',
@@ -1880,6 +1886,7 @@ const defaultLocales: LocaleTree = {
choosePlaylist: 'Choose a Playlist',
addToPlaylist: 'Add to Playlist',
addedToPlaylist: 'Added to playlist.',
+ alreadyAdded: 'Added',
removeFromPlaylist: 'Remove from Playlist',
removedFromPlaylist: 'Removed from playlist.',
createPlaylistFirst: 'Create a playlist before adding this song.',
@@ -1910,6 +1917,7 @@ const defaultLocales: LocaleTree = {
playlist_not_found: 'That playlist no longer exists.',
playlist_song_limit: 'That playlist has reached its song limit.',
invalid_song: 'That song cannot be added to this playlist.',
+ song_already_in_playlist: 'That song is already in this playlist.',
rate_limited: 'Too many music changes. Try again shortly.',
playback_failed: 'This song could not be played.',
request_failed: 'Music is temporarily unavailable.',
diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue
index 5564c54..9ba20b8 100644
--- a/frontend/src/views/apps/MusicApp.vue
+++ b/frontend/src/views/apps/MusicApp.vue
@@ -25,6 +25,7 @@ import {
kToolbarPane,
} from 'konsta/vue'
import {
+ Check,
CirclePlus,
Ellipsis,
ExternalLink,
@@ -43,19 +44,25 @@ import {
X,
} from 'lucide-vue-next'
import type { CSSProperties } from 'vue'
-import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useMusicStore } from '@/stores/music'
import { usePhoneStore } from '@/stores/phone'
import type { MusicPlaylist, MusicTrack } from '@/types/music'
type MusicTab = 'library' | 'playlists' | 'search'
-type MusicSheet = 'playlist' | 'playlist-picker' | 'rename' | 'youtube'
+type MusicSheet =
+ | 'playlist'
+ | 'playlist-picker'
+ | 'rename'
+ | 'track-picker'
+ | 'youtube'
const MUSIC_POPOVER_WIDTH = 240
const MUSIC_POPOVER_ITEM_HEIGHT = 52
const MUSIC_POPOVER_INSET = 8
const MUSIC_POPOVER_GAP = 7
+const MUSIC_SHEET_TRANSITION_MS = 420
const music = useMusicStore()
const phone = usePhoneStore()
@@ -74,11 +81,13 @@ const youtubeUrl = ref('')
const youtubeTitle = ref('')
const youtubeArtist = ref('')
const playlistName = ref('')
+const playlistCreationTrack = ref(null)
const searchQuery = ref('')
const toastText = ref('')
const confirmRemoveTrack = ref(false)
const confirmDeletePlaylist = ref(false)
const scrollEl = ref(null)
+let playerPickerTimer: number | null = null
const allTracks = computed(() => music.allTracks)
const normalizedSearch = computed(() => searchQuery.value.trim().toLowerCase())
@@ -93,12 +102,19 @@ const searchResults = computed(() => {
const playlistTracks = computed(() =>
activePlaylist.value ? music.tracksForPlaylist(activePlaylist.value) : [],
)
+const availablePlaylistTracks = computed(() => {
+ const playlist = activePlaylist.value
+ if (!playlist) return []
+ return allTracks.value.filter((track) => !playlistHasTrack(playlist, track))
+})
const recentTracks = computed(() => allTracks.value.slice(0, 6))
const featuredTrack = computed(() => allTracks.value[0] ?? null)
const sheetTitle = computed(() => {
if (activeSheet.value === 'youtube') return phone.t('Apps.music.addYouTube')
if (activeSheet.value === 'playlist-picker')
return phone.t('Apps.music.choosePlaylist')
+ if (activeSheet.value === 'track-picker')
+ return phone.t('Apps.music.addSongs')
if (activeSheet.value === 'rename')
return phone.t('Apps.music.renamePlaylist')
return phone.t('Apps.music.newPlaylist')
@@ -128,6 +144,11 @@ function closeMenus(): void {
actionMenuOpened.value = false
}
+function dismissMenus(): void {
+ closeMenus()
+ actionTrack.value = null
+}
+
function positionPopover(target: HTMLElement, itemCount: number): boolean {
const app = target.closest('.music-app')
if (!app) return false
@@ -168,7 +189,8 @@ function positionPopover(target: HTMLElement, itemCount: number): boolean {
function openAddMenu(event: MouseEvent): void {
const target = event.currentTarget as HTMLElement
- if (!positionPopover(target, 2)) return
+ if (!positionPopover(target, activePlaylist.value ? 3 : 2)) return
+ actionTrack.value = null
actionMenuOpened.value = false
addMenuOpened.value = true
}
@@ -190,6 +212,37 @@ function closeSheet(): void {
if (music.isLoading) return
activeSheet.value = null
music.error = ''
+ actionTrack.value = null
+ playlistCreationTrack.value = null
+}
+
+function openNewPlaylist(track: MusicTrack | null = null): void {
+ if (music.isLoading) return
+ playlistCreationTrack.value = track
+ openSheet('playlist')
+}
+
+function openPlaylistPicker(track: MusicTrack | null): void {
+ if (!track) return
+ actionTrack.value = track
+ openSheet('playlist-picker')
+}
+
+function openActivePlaylistTrackPicker(): void {
+ if (!activePlaylist.value) return
+ actionTrack.value = null
+ openSheet('track-picker')
+}
+
+function openCurrentTrackPlaylistPicker(): void {
+ const track = music.currentTrack
+ if (!track) return
+ playerOpened.value = false
+ if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
+ playerPickerTimer = window.setTimeout(() => {
+ playerPickerTimer = null
+ if (!playerOpened.value) openPlaylistPicker(track)
+ }, MUSIC_SHEET_TRANSITION_MS)
}
function requestDeletePlaylist(): void {
@@ -202,6 +255,11 @@ function requestRemoveTrack(): void {
confirmRemoveTrack.value = true
}
+function cancelRemoveTrack(): void {
+ confirmRemoveTrack.value = false
+ actionTrack.value = null
+}
+
function openTrackMenu(event: MouseEvent, track: MusicTrack): void {
event.stopPropagation()
actionTrack.value = track
@@ -262,29 +320,63 @@ async function submitYouTube(): Promise {
}
async function submitPlaylist(): Promise {
+ if (music.isLoading) return
const name = playlistName.value.trim()
if (!name) return
- const success =
- activeSheet.value === 'rename' && activePlaylist.value
- ? await music.renamePlaylist(activePlaylist.value.id, name)
- : await music.createPlaylist(name)
- if (!success) return
if (activeSheet.value === 'rename' && activePlaylist.value) {
+ if (!(await music.renamePlaylist(activePlaylist.value.id, name))) return
activePlaylist.value =
music.playlists.find(
(playlist) => playlist.id === activePlaylist.value?.id,
) ?? null
showToast('Apps.music.playlistRenamed')
+ activeSheet.value = null
+ return
+ }
+
+ const previousIds = new Set(music.playlists.map((playlist) => playlist.id))
+ const pendingTrack = playlistCreationTrack.value
+ if (!(await music.createPlaylist(name))) return
+
+ const createdPlaylist = music.playlists.find(
+ (playlist) => !previousIds.has(playlist.id),
+ )
+ if (pendingTrack) {
+ playlistCreationTrack.value = null
+ if (!createdPlaylist) {
+ music.error = 'request_failed'
+ activeSheet.value = 'playlist-picker'
+ return
+ }
+ if (!(await music.addToPlaylist(createdPlaylist.id, pendingTrack))) {
+ activeSheet.value = 'playlist-picker'
+ return
+ }
+ showToast('Apps.music.playlistCreatedWithSong')
+ actionTrack.value = null
} else {
showToast('Apps.music.playlistCreated')
}
+ playlistCreationTrack.value = null
activeSheet.value = null
}
async function addTrackToPlaylist(playlist: MusicPlaylist): Promise {
- if (!actionTrack.value) return
- if (await music.addToPlaylist(playlist.id, actionTrack.value)) {
+ const track = actionTrack.value
+ if (music.isLoading || !track || playlistHasTrack(playlist, track)) return
+ if (await music.addToPlaylist(playlist.id, track)) {
activeSheet.value = null
+ actionTrack.value = null
+ showToast('Apps.music.addedToPlaylist')
+ }
+}
+
+async function addTrackToActivePlaylist(track: MusicTrack): Promise {
+ const playlist = activePlaylist.value
+ if (music.isLoading || !playlist || playlistHasTrack(playlist, track)) return
+ if (await music.addToPlaylist(playlist.id, track)) {
+ activePlaylist.value =
+ music.playlists.find((candidate) => candidate.id === playlist.id) ?? null
showToast('Apps.music.addedToPlaylist')
}
}
@@ -296,6 +388,7 @@ async function removeFromActivePlaylist(): Promise {
activePlaylist.value =
music.playlists.find((playlist) => playlist.id === playlistId) ?? null
actionMenuOpened.value = false
+ actionTrack.value = null
showToast('Apps.music.removedFromPlaylist')
}
}
@@ -305,6 +398,7 @@ async function removePersonalTrack(): Promise {
if (await music.removeYouTube(actionTrack.value.id)) {
confirmRemoveTrack.value = false
actionMenuOpened.value = false
+ actionTrack.value = null
showToast('Apps.music.songRemoved')
}
}
@@ -322,6 +416,18 @@ function playlistArtwork(playlist: MusicPlaylist): MusicTrack[] {
return music.tracksForPlaylist(playlist).slice(0, 4)
}
+function playlistHasTrack(playlist: MusicPlaylist, track: MusicTrack): boolean {
+ return playlist.entries.some(
+ (entry) => entry.source === track.source && entry.songId === track.id,
+ )
+}
+
+function actionTrackIsInPlaylist(playlist: MusicPlaylist): boolean {
+ return Boolean(
+ actionTrack.value && playlistHasTrack(playlist, actionTrack.value),
+ )
+}
+
function fallbackArtwork(
track: Pick,
): Record {
@@ -367,6 +473,10 @@ watch(
onMounted(() => {
void music.load()
})
+
+onBeforeUnmount(() => {
+ if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
+})
@@ -448,17 +558,29 @@ onMounted(() => {
})
}}
-
-
- {{ phone.t('Apps.music.play') }}
-
+
+
+
+ {{ phone.t('Apps.music.play') }}
+
+
+
+ {{ phone.t('Apps.music.addSongs') }}
+
+
@@ -649,7 +771,7 @@ onMounted(() => {
{{ phone.t('Apps.music.noPlaylists') }}
{{ phone.t('Apps.music.noPlaylistsBody') }}
-
+
{{ phone.t('Apps.music.newPlaylist') }}
@@ -797,7 +919,7 @@ onMounted(() => {
class="music-popover-dismiss"
aria-hidden="true"
tabindex="-1"
- @click="closeMenus"
+ @click="dismissMenus"
/>
{
>
+
+ {{ phone.t('Apps.music.addSongs') }}
+
{{ phone.t('Apps.music.renamePlaylist') }}
@@ -828,7 +956,7 @@ onMounted(() => {
{{ phone.t('Apps.music.addYouTube') }}
-
+
{{ phone.t('Apps.music.newPlaylist') }}
@@ -849,7 +977,7 @@ onMounted(() => {
{{ phone.t('Apps.music.addToPlaylist') }}
@@ -880,7 +1008,9 @@ onMounted(() => {
{{
- phone.t('Common.cancel')
+ phone.t(
+ activeSheet === 'track-picker' ? 'Common.done' : 'Common.cancel',
+ )
}}
{{ sheetTitle }}
@@ -943,7 +1073,13 @@ onMounted(() => {
+
+
+
+ {{ phone.t('Apps.music.alreadyAdded') }}
+
+
+
+
+ {{ phone.t('Apps.music.newPlaylist') }}
+
{{ phone.t('Apps.music.noPlaylists') }}
{{ phone.t('Apps.music.createPlaylistFirst') }}
- {{
- phone.t('Apps.music.newPlaylist')
- }}
+ {{ phone.t('Apps.music.newPlaylist') }}
+
+
+ {{ errorText() }}
+
+
+
+
+ {{ phone.t('Apps.music.addSongsBody') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ phone.t(
+ allTracks.length
+ ? 'Apps.music.allSongsAdded'
+ : 'Apps.music.emptyLibrary',
+ )
+ }}
+ {{
+ phone.t(
+ allTracks.length
+ ? 'Apps.music.allSongsAddedBody'
+ : 'Apps.music.emptyLibraryBody',
+ )
+ }}
{{ errorText() }}
@@ -976,6 +1191,7 @@ onMounted(() => {
:label="phone.t('Apps.music.playlistName')"
input-id="music-playlist-name"
maxlength="80"
+ :disabled="music.isLoading"
:placeholder="phone.t('Apps.music.playlistPlaceholder')"
:value="playlistName"
@input="playlistName = eventValue($event)"
@@ -1014,7 +1230,14 @@ onMounted(() => {
{{ phone.t('Apps.music.nowPlaying') }}
-
+
+
+
{
:opened="confirmRemoveTrack"
:title="phone.t('Apps.music.removeSongTitle')"
:content="phone.t('Apps.music.removeSongBody')"
- @backdropclick="confirmRemoveTrack = false"
+ @backdropclick="cancelRemoveTrack"
>
- {{
+ {{
phone.t('Common.cancel')
}}
{{
@@ -1494,6 +1717,18 @@ onMounted(() => {
width: 100%;
}
+.music-playlist-actions {
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 9px;
+}
+
+.music-playlist-actions :deep(button) {
+ min-width: 0;
+ padding-inline: 10px;
+}
+
.music-mini-player {
position: absolute;
z-index: 32;
@@ -1627,6 +1862,19 @@ onMounted(() => {
margin-top: 12px;
}
+.music-picker-added {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--music-muted);
+ font-size: 11px;
+}
+
+.music-picker-create {
+ width: calc(100% - 32px);
+ margin: 14px 16px 0 !important;
+}
+
.music-popover-dismiss {
position: absolute;
z-index: 199;
@@ -1688,6 +1936,10 @@ onMounted(() => {
color: #fff;
}
+.music-player > header > :last-child {
+ justify-self: end;
+}
+
.music-player-art {
width: 100%;
aspect-ratio: 1;
diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs
index 3d5c5d1..a94059e 100644
--- a/frontend/testserver/index.cjs
+++ b/frontend/testserver/index.cjs
@@ -1675,17 +1675,22 @@ app.post('/api/:endpoint', async (request, response) => {
return
}
if (
- !playlist.entries.some(
+ playlist.entries.some(
(entry) =>
entry.source === request.body.source &&
entry.songId === request.body.songId,
)
) {
- playlist.entries.push({
- source: request.body.source,
- songId: request.body.songId,
+ response.json({
+ success: false,
+ error: 'song_already_in_playlist',
})
+ return
}
+ playlist.entries.push({
+ source: request.body.source,
+ songId: request.body.songId,
+ })
response.json({ success: true, data: musicBootstrap() })
return
}
diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua
index 6b5c92c..f6c2e6f 100644
--- a/sky_phone/config/locales/en.lua
+++ b/sky_phone/config/locales/en.lua
@@ -704,11 +704,14 @@ Locales["en"] = {
youtubeTitlePlaceholder = "Use the YouTube video title", youtubeArtist = "Artist (optional)",
youtubeArtistPlaceholder = "Use the YouTube channel", addToLibrary = "Add to Library",
songAdded = "Song added to your library.", songRemoved = "Song removed from your library.",
- newPlaylist = "New Playlist", renamePlaylist = "Rename Playlist", playlistBody = "Give this playlist a name you will recognize.",
+ newPlaylist = "New Playlist", renamePlaylist = "Rename Playlist", addSongs = "Add Songs",
+ addSongsBody = "Choose songs from your library for this playlist.", allSongsAdded = "All Songs Added",
+ allSongsAddedBody = "Every song in your library is already in this playlist.", playlistBody = "Give this playlist a name you will recognize.",
playlistName = "Playlist Name", playlistPlaceholder = "My Playlist", playlistCreated = "Playlist created.",
+ playlistCreatedWithSong = "Playlist created and song added.",
playlistRenamed = "Playlist renamed.", playlistDeleted = "Playlist deleted.", playlistActions = "Playlist actions",
deletePlaylist = "Delete Playlist", deletePlaylistTitle = "Delete Playlist?", deletePlaylistBody = "The playlist will be deleted. Songs stay in your library.",
- choosePlaylist = "Choose a Playlist", addToPlaylist = "Add to Playlist", addedToPlaylist = "Added to playlist.",
+ choosePlaylist = "Choose a Playlist", addToPlaylist = "Add to Playlist", addedToPlaylist = "Added to playlist.", alreadyAdded = "Added",
removeFromPlaylist = "Remove from Playlist", removedFromPlaylist = "Removed from playlist.", createPlaylistFirst = "Create a playlist before adding this song.",
songActions = "Song actions", removeFromLibrary = "Remove from Library", removeSongTitle = "Remove Song?",
removeSongBody = "This YouTube song will also be removed from your playlists.", songCount = "{count} songs",
@@ -722,7 +725,7 @@ Locales["en"] = {
song_limit = "Your personal song limit has been reached.", song_not_found = "That song is no longer in your library.",
invalid_playlist = "Choose a valid playlist name.", playlist_limit = "Your playlist limit has been reached.",
playlist_not_found = "That playlist no longer exists.", playlist_song_limit = "That playlist has reached its song limit.",
- invalid_song = "That song cannot be added to this playlist.", rate_limited = "Too many music changes. Try again shortly.",
+ invalid_song = "That song cannot be added to this playlist.", song_already_in_playlist = "That song is already in this playlist.", rate_limited = "Too many music changes. Try again shortly.",
playback_failed = "This song could not be played.", request_failed = "Music is temporarily unavailable.", default = "The Music request failed.",
},
},
diff --git a/sky_phone/source/server/music.lua b/sky_phone/source/server/music.lua
index d6a54ea..63eba59 100644
--- a/sky_phone/source/server/music.lua
+++ b/sky_phone/source/server/music.lua
@@ -190,6 +190,28 @@ local function affected_rows(result)
return type(result) == "table" and tonumber(result.affectedRows) or 0
end
+local playlist_add_locks = {}
+
+local function with_playlist_add_lock(playlist_id, callback)
+ local previous_lock = playlist_add_locks[playlist_id]
+ local current_lock = promise.new()
+ playlist_add_locks[playlist_id] = current_lock
+
+ if previous_lock then
+ Citizen.Await(previous_lock)
+ end
+
+ local success, result = xpcall(callback, debug.traceback)
+ current_lock:resolve(true)
+ if playlist_add_locks[playlist_id] == current_lock then
+ playlist_add_locks[playlist_id] = nil
+ end
+ if not success then
+ error(result, 0)
+ end
+ return result
+end
+
local function uuid()
local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local id = rows[1] and rows[1].id
@@ -701,27 +723,40 @@ Bridge.Callbacks.Register("sky_phone:music:add-to-playlist", function(source, da
return { success = false, error = "invalid_song" }
end
- local counts = Bridge.Database.Query([[
- SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`
- FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
- ]], { playlist_id })
- if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
- return { success = false, error = "playlist_song_limit" }
+ local mutation_error = with_playlist_add_lock(playlist_id, function()
+ local counts = Bridge.Database.Query([[
+ SELECT COUNT(*) AS `count`, COALESCE(MAX(`position`), 0) AS `last_position`,
+ COALESCE(MAX(`source` = ? AND `song_id` = ?), 0) AS `already_exists`
+ FROM `sky_phone_music_playlist_items` WHERE `playlist_id` = ?
+ ]], { source_type, song_id, playlist_id })
+ if tonumber(counts[1] and counts[1].already_exists) == 1 then
+ return "song_already_in_playlist"
+ end
+ if (tonumber(counts[1] and counts[1].count) or 0) >= Config.Music.MaximumPlaylistSongs then
+ return "playlist_song_limit"
+ end
+ local insert_result = Bridge.Database.Query([[
+ INSERT IGNORE INTO `sky_phone_music_playlist_items`
+ (`playlist_id`, `source`, `song_id`, `position`)
+ VALUES (?, ?, ?, ?)
+ ]], {
+ playlist_id,
+ source_type,
+ song_id,
+ (tonumber(counts[1] and counts[1].last_position) or 0) + 1,
+ })
+ if affected_rows(insert_result) ~= 1 then
+ return "song_already_in_playlist"
+ end
+ Bridge.Database.Query(
+ "UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
+ { playlist_id }
+ )
+ return nil
+ end)
+ if mutation_error then
+ return { success = false, error = mutation_error }
end
- Bridge.Database.Query([[
- INSERT IGNORE INTO `sky_phone_music_playlist_items`
- (`playlist_id`, `source`, `song_id`, `position`)
- VALUES (?, ?, ?, ?)
- ]], {
- playlist_id,
- source_type,
- song_id,
- (tonumber(counts[1] and counts[1].last_position) or 0) + 1,
- })
- Bridge.Database.Query(
- "UPDATE `sky_phone_music_playlists` SET `updated_at` = CURRENT_TIMESTAMP WHERE `id` = ?",
- { playlist_id }
- )
return { success = true, data = bootstrap(account_id, imei) }
end)