feat: add playlist song picker

This commit is contained in:
Dominik
2026-08-09 22:11:21 +02:00
parent cf0a70d834
commit 4f6f84bdb5
6 changed files with 416 additions and 60 deletions
+53
View File
@@ -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)
})
})
+8
View File
@@ -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.',
+285 -33
View File
@@ -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<MusicTrack | null>(null)
const searchQuery = ref('')
const toastText = ref('')
const confirmRemoveTrack = ref(false)
const confirmDeletePlaylist = ref(false)
const scrollEl = ref<HTMLElement | null>(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<HTMLElement>('.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<void> {
}
async function submitPlaylist(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<MusicTrack, 'id'>,
): Record<string, string> {
@@ -367,6 +473,10 @@ watch(
onMounted(() => {
void music.load()
})
onBeforeUnmount(() => {
if (playerPickerTimer !== null) window.clearTimeout(playerPickerTimer)
})
</script>
<template>
@@ -448,17 +558,29 @@ onMounted(() => {
})
}}
</p>
<k-button
large
rounded
:disabled="!playlistTracks.length"
@click="
playlistTracks[0] && playTrack(playlistTracks[0], playlistTracks)
"
>
<Play :size="18" fill="currentColor" />
{{ phone.t('Apps.music.play') }}
</k-button>
<div class="music-playlist-actions">
<k-button
large
rounded
:disabled="!playlistTracks.length"
@click="
playlistTracks[0] &&
playTrack(playlistTracks[0], playlistTracks)
"
>
<Play :size="18" fill="currentColor" />
{{ phone.t('Apps.music.play') }}
</k-button>
<k-button
large
rounded
tonal
@click="openActivePlaylistTrackPicker"
>
<CirclePlus :size="18" />
{{ phone.t('Apps.music.addSongs') }}
</k-button>
</div>
</section>
<k-list v-if="playlistTracks.length" nested class="music-track-list">
@@ -649,7 +771,7 @@ onMounted(() => {
<ListMusic :size="48" />
<strong>{{ phone.t('Apps.music.noPlaylists') }}</strong>
<span>{{ phone.t('Apps.music.noPlaylistsBody') }}</span>
<k-button rounded @click="openSheet('playlist')">
<k-button rounded @click="openNewPlaylist()">
<Plus :size="17" />
{{ phone.t('Apps.music.newPlaylist') }}
</k-button>
@@ -797,7 +919,7 @@ onMounted(() => {
class="music-popover-dismiss"
aria-hidden="true"
tabindex="-1"
@click="closeMenus"
@click="dismissMenus"
/>
<section
v-if="addMenuOpened"
@@ -812,6 +934,12 @@ onMounted(() => {
>
<k-list nested>
<template v-if="activePlaylist">
<k-list-button
link-component="button"
@click="openActivePlaylistTrackPicker"
>
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addSongs') }}
</k-list-button>
<k-list-button link-component="button" @click="openSheet('rename')">
<ListMusic :size="18" />
{{ phone.t('Apps.music.renamePlaylist') }}
@@ -828,7 +956,7 @@ onMounted(() => {
<k-list-button link-component="button" @click="openSheet('youtube')">
<ExternalLink :size="18" /> {{ phone.t('Apps.music.addYouTube') }}
</k-list-button>
<k-list-button link-component="button" @click="openSheet('playlist')">
<k-list-button link-component="button" @click="openNewPlaylist()">
<ListMusic :size="18" /> {{ phone.t('Apps.music.newPlaylist') }}
</k-list-button>
</template>
@@ -849,7 +977,7 @@ onMounted(() => {
<k-list nested>
<k-list-button
link-component="button"
@click="openSheet('playlist-picker')"
@click="openPlaylistPicker(actionTrack)"
>
<CirclePlus :size="18" /> {{ phone.t('Apps.music.addToPlaylist') }}
</k-list-button>
@@ -880,7 +1008,9 @@ onMounted(() => {
<section class="music-sheet-content">
<header>
<k-link component="button" @click="closeSheet">{{
phone.t('Common.cancel')
phone.t(
activeSheet === 'track-picker' ? 'Common.done' : 'Common.cancel',
)
}}</k-link>
<strong>{{ sheetTitle }}</strong>
<span />
@@ -943,7 +1073,13 @@ onMounted(() => {
<k-list-item
v-for="playlist in music.playlists"
:key="playlist.id"
link
:link="!actionTrackIsInPlaylist(playlist)"
link-component="button"
:chevron="false"
:link-props="{
type: 'button',
disabled: music.isLoading || actionTrackIsInPlaylist(playlist),
}"
:title="playlist.name"
:subtitle="
phone.t('Apps.music.songCount', {
@@ -953,15 +1089,94 @@ onMounted(() => {
@click="addTrackToPlaylist(playlist)"
>
<template #media><ListMusic :size="22" /></template>
<template #after>
<span
v-if="actionTrackIsInPlaylist(playlist)"
class="music-picker-added"
>
<Check :size="16" />
{{ phone.t('Apps.music.alreadyAdded') }}
</span>
<CirclePlus v-else :size="19" />
</template>
</k-list-item>
</k-list>
<k-button
v-if="music.playlists.length"
rounded
tonal
class="music-picker-create"
:disabled="music.isLoading"
@click="openNewPlaylist(actionTrack)"
>
<Plus :size="17" /> {{ phone.t('Apps.music.newPlaylist') }}
</k-button>
<k-block v-else class="music-empty" inset>
<ListMusic :size="42" />
<strong>{{ phone.t('Apps.music.noPlaylists') }}</strong>
<span>{{ phone.t('Apps.music.createPlaylistFirst') }}</span>
<k-button rounded @click="openSheet('playlist')">{{
phone.t('Apps.music.newPlaylist')
}}</k-button>
<k-button
rounded
:disabled="music.isLoading"
@click="openNewPlaylist(actionTrack)"
>{{ phone.t('Apps.music.newPlaylist') }}</k-button
>
</k-block>
<p v-if="music.error" class="music-form-error" role="alert">
{{ errorText() }}
</p>
</template>
<template v-else-if="activeSheet === 'track-picker'">
<p>{{ phone.t('Apps.music.addSongsBody') }}</p>
<k-list
v-if="availablePlaylistTracks.length"
inset
strong
class="music-picker-list"
>
<k-list-item
v-for="track in availablePlaylistTracks"
:key="`${track.source}:${track.id}`"
link
link-component="button"
:chevron="false"
:link-props="{ type: 'button', disabled: music.isLoading }"
:title="track.title"
:subtitle="track.artist"
@click="addTrackToActivePlaylist(track)"
>
<template #media>
<span class="music-row-art" :style="fallbackArtwork(track)">
<img
v-if="track.artwork"
:src="track.artwork"
alt=""
@error="hideBrokenArtwork"
/>
<Music2 v-else :size="20" />
</span>
</template>
<template #after><CirclePlus :size="20" /></template>
</k-list-item>
</k-list>
<k-block v-else class="music-empty" inset>
<Check v-if="allTracks.length" :size="42" />
<Music2 v-else :size="42" />
<strong>{{
phone.t(
allTracks.length
? 'Apps.music.allSongsAdded'
: 'Apps.music.emptyLibrary',
)
}}</strong>
<span>{{
phone.t(
allTracks.length
? 'Apps.music.allSongsAddedBody'
: 'Apps.music.emptyLibraryBody',
)
}}</span>
</k-block>
<p v-if="music.error" class="music-form-error" role="alert">
{{ 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(() => {
<X :size="20" />
</k-link>
<span>{{ phone.t('Apps.music.nowPlaying') }}</span>
<i />
<k-link
component="button"
icon-only
:aria-label="phone.t('Apps.music.addToPlaylist')"
@click="openCurrentTrackPlaylistPicker"
>
<CirclePlus :size="21" />
</k-link>
</header>
<div
class="music-player-art"
@@ -1096,10 +1319,10 @@ onMounted(() => {
:opened="confirmRemoveTrack"
:title="phone.t('Apps.music.removeSongTitle')"
:content="phone.t('Apps.music.removeSongBody')"
@backdropclick="confirmRemoveTrack = false"
@backdropclick="cancelRemoveTrack"
>
<template #buttons>
<k-dialog-button @click="confirmRemoveTrack = false">{{
<k-dialog-button @click="cancelRemoveTrack">{{
phone.t('Common.cancel')
}}</k-dialog-button>
<k-dialog-button strong @click="removePersonalTrack">{{
@@ -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;
+9 -4
View File
@@ -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
}
+6 -3
View File
@@ -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.",
},
},
+55 -20
View File
@@ -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)