diff --git a/README.md b/README.md index 836763f..9748887 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,47 @@ MusicTracks = { } ``` +## Music app + +The standalone `Music` app plays audio only inside the current player's NUI. It never creates a +world sound, voice channel, positional event, or 3D-audio state that another player can hear. + +Server-owned MP3/OGG tracks and their optional artwork live directly in +`sky_phone/config/music`. Define only their stable ID, title, and artist in +`sky_phone/config/music.lua`: + +```lua +Tracks = { + { + Id = "night-drive", + Title = "Night Drive", + Artist = "Sky Records", + }, +} +``` + +The resource searches `config/music` and all of its subdirectories for files whose name matches +the ID. For the example above, place `night-drive.ogg` or `night-drive.mp3` anywhere below that +directory. Optional artwork uses the same name with `.webp`, `.png`, `.jpg`, or `.jpeg`. If both +audio formats exist, OGG wins; artwork priority is WEBP, PNG, JPG, then JPEG. More than one file +with the selected extension and ID is ambiguous and the server reports it in the console. Folder +names may contain spaces, but must not contain path separators or control characters. Do not name +a folder itself with a supported audio or artwork extension. + +No frontend build is needed when tracks change. Restart the resource so FiveM republishes the +files and reloads the track configuration. Keep existing track IDs stable because playlists store +those IDs. Moving matching files between subdirectories does not affect playlists. + +When upgrading from the previous path-based configuration, rename each audio and artwork file to +its existing ID and remove the `File` and `Artwork` fields. Do not change the ID itself, otherwise +existing playlist entries can no longer resolve the track. + +Players can add public YouTube video links to their own library. Metadata is requested through +YouTube's oEmbed endpoint on the server, while playback uses the embedded YouTube player only on +that player's NUI. Personal songs and playlists are stored per linked iFruit account, or per phone +IMEI while signed out, in the `sky_phone_music_*` tables. Limits and rate controls are configured in +`config/music.lua`. + ## FlipTok accounts FlipTok profiles use their own username and password login. Registration requires a linked iFruit diff --git a/frontend/src/assets/img/app-icons/music.svg b/frontend/src/assets/img/app-icons/music.svg new file mode 100644 index 0000000..308c035 --- /dev/null +++ b/frontend/src/assets/img/app-icons/music.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/components/PhoneControlCenter.vue b/frontend/src/components/PhoneControlCenter.vue index f7656b1..6c62f6a 100644 --- a/frontend/src/components/PhoneControlCenter.vue +++ b/frontend/src/components/PhoneControlCenter.vue @@ -7,6 +7,7 @@ import { Camera, Flashlight, Moon, + Pause, Plane, Play, Signal, @@ -28,6 +29,7 @@ import { import { useRouter } from 'vue-router' import { usePhoneStore } from '@/stores/phone' +import { useMusicStore } from '@/stores/music' import { nuiCall } from '@/utils/nui' const props = defineProps<{ opened: boolean }>() @@ -41,6 +43,7 @@ type ConnectivityPreference = | 'wifiEnabled' const phone = usePhoneStore() +const music = useMusicStore() const router = useRouter() const panel = ref(null) const brightness = ref(phone.preferences.settings.screenBrightness) @@ -315,27 +318,40 @@ onBeforeUnmount(() => { >
{{ phone.t('ControlCenter.media') }} - {{ phone.t('ControlCenter.notPlaying') }} + {{ + music.currentTrack?.title ?? phone.t('ControlCenter.notPlaying') + }} + {{ + music.currentTrack.artist + }}
diff --git a/frontend/src/components/SpringboardWidget.vue b/frontend/src/components/SpringboardWidget.vue index 57d8276..f851a42 100644 --- a/frontend/src/components/SpringboardWidget.vue +++ b/frontend/src/components/SpringboardWidget.vue @@ -250,6 +250,7 @@ function openWidget(): void { clock: '/apps/clock', contacts: '/apps/phone', date: '/apps/calendar', + music: '/apps/music', transactions: '/apps/banking', wallet: '/apps/banking', weather: '/apps/weather', diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 88c34a9..57d2c5c 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -52,6 +52,12 @@ describe('app registry', () => { labelKey: 'Apps.house.name', route: '/apps/house', }) + expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({ + category: 'utilities', + gridOrder: 26, + labelKey: 'Apps.music.name', + route: '/apps/music', + }) expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({ gridOrder: 21, labelKey: 'Apps.calendar.name', @@ -121,6 +127,7 @@ describe('app registry', () => { expect(isPhoneAppId('photos')).toBe(true) expect(isPhoneAppId('clock')).toBe(true) expect(isPhoneAppId('skyride')).toBe(true) + expect(isPhoneAppId('music')).toBe(true) expect( PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id), ).toEqual([ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 1d3a57c..3a27840 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -28,6 +28,7 @@ import { MapPinHouse, Flame, House, + Music2, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -62,6 +63,7 @@ import flareIcon from '@/assets/img/app-icons/flare.svg' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' import skyRideIcon from '@/assets/img/app-icons/skyride.svg' +import musicIcon from '@/assets/img/app-icons/music.svg' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -69,6 +71,20 @@ import type { } from '@/types/apps' export const PHONE_APPS: PhoneAppDefinition[] = [ + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/MusicApp.vue')), + ), + dockOrder: null, + gridOrder: 26, + icon: markRaw(Music2), + iconClass: 'app-icon--music', + iconImage: musicIcon, + id: 'music', + labelKey: 'Apps.music.name', + route: '/apps/music', + }, { category: 'social', component: markRaw( diff --git a/frontend/src/services/widgetServices.ts b/frontend/src/services/widgetServices.ts index ac6e05e..2e9c232 100644 --- a/frontend/src/services/widgetServices.ts +++ b/frontend/src/services/widgetServices.ts @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useBankingStore } from '@/stores/banking' import { useCallsStore } from '@/stores/calls' +import { useMusicStore } from '@/stores/music' import { usePhoneStore } from '@/stores/phone' import { useWeatherStore } from '@/stores/weather' @@ -9,14 +10,6 @@ const now = ref(new Date()) let clockConsumers = 0 let clockInterval: number | undefined -const tracks = [ - { artist: 'Sky Radio', title: 'Night Drive' }, - { artist: 'Los Santos FM', title: 'Pacific Coast' }, - { artist: 'Mirror Park', title: 'After Hours' }, -] -const trackIndex = ref(0) -const playing = ref(false) - export function useClockService() { const phone = usePhoneStore() onMounted(() => { @@ -80,15 +73,21 @@ export function useWeatherService() { } export function useMusicService() { + const music = useMusicStore() return { - current: computed(() => tracks[trackIndex.value]), + current: computed( + () => + music.currentTrack ?? { + artist: 'Music', + title: 'Not Playing', + }, + ), next(): void { - trackIndex.value = (trackIndex.value + 1) % tracks.length - playing.value = true + void music.next() }, - playing, + playing: computed(() => music.isPlaying), toggle(): void { - playing.value = !playing.value + void music.toggle() }, } } diff --git a/frontend/src/stores/music.test.ts b/frontend/src/stores/music.test.ts new file mode 100644 index 0000000..d56673f --- /dev/null +++ b/frontend/src/stores/music.test.ts @@ -0,0 +1,253 @@ +import { createPinia, setActivePinia } from 'pinia' +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} 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 + + currentTime = 0 + duration = 0 + paused = true + preload = '' + seekable = { + end: () => 0, + length: 0, + start: () => 0, + } as TimeRanges + src = '' + volume = 1 + + load = vi.fn() + + constructor() { + super() + FakeAudio.latest = this + } + + pause(): void { + if (this.paused) return + this.paused = true + this.dispatchEvent(new Event('pause')) + } + + async play(): Promise { + this.paused = false + this.dispatchEvent(new Event('play')) + } + + removeAttribute(name: string): void { + if (name === 'src') this.src = '' + } + + reset(): void { + this.currentTime = 0 + this.duration = 0 + this.paused = true + this.seekable = { + end: () => 0, + length: 0, + start: () => 0, + } as TimeRanges + this.src = '' + this.volume = 1 + this.load.mockClear() + } +} + +const fetchMock = vi.fn() +const createObjectUrlMock = vi.fn<(blob: Blob) => string>() +const revokeObjectUrlMock = vi.fn<(url: string) => void>() +let fakeAudio: FakeAudio +let useMusicStore: (typeof import('@/stores/music'))['useMusicStore'] + +function track(id: string, extension: 'mp3' | 'ogg' = 'ogg'): MusicTrack { + return { + artist: 'Sky Records', + artwork: null, + id, + source: 'server', + title: id, + url: `https://cfx-nui-sky_phone/config/music/${id}.${extension}`, + } +} + +function audioResponse(bytes = [1, 2, 3]): Response { + return { + arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from(bytes).buffer), + ok: true, + status: 200, + } as unknown as Response +} + +beforeAll(async () => { + vi.stubGlobal('Audio', FakeAudio) + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('window', { + location: { + href: 'https://cfx-nui-sky_phone/source/html/index.html#/music', + }, + }) + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectUrlMock, + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectUrlMock, + }) + ;({ useMusicStore } = await import('@/stores/music')) + if (!FakeAudio.latest) + throw new Error('Music audio test double was not used.') + fakeAudio = FakeAudio.latest +}) + +beforeEach(() => { + setActivePinia(createPinia()) + fakeAudio.reset() + fetchMock.mockReset() + createObjectUrlMock.mockReset() + createObjectUrlMock.mockReturnValue('blob:sky-music') + revokeObjectUrlMock.mockReset() + vi.mocked(nuiCall).mockReset() +}) + +afterEach(() => { + useMusicStore().stop() +}) + +describe('music server playback', () => { + it('loads CFX-NUI audio into a seekable Blob URL', async () => { + fetchMock.mockResolvedValue(audioResponse()) + const music = useMusicStore() + + await music.play(track('night-drive'), [track('night-drive')]) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://cfx-nui-sky_phone/config/music/night-drive.ogg', + { signal: expect.any(AbortSignal) }, + ) + expect(createObjectUrlMock).toHaveBeenCalledOnce() + expect(createObjectUrlMock.mock.calls[0]?.[0].type).toBe('audio/ogg') + expect(fakeAudio.src).toBe('blob:sky-music') + expect(music.isPlaying).toBe(true) + }) + + it('updates duration and seeks within the track bounds', async () => { + fetchMock.mockResolvedValue(audioResponse()) + const music = useMusicStore() + const serverTrack = track('bounded-seek', 'mp3') + await music.play(serverTrack, [serverTrack]) + + fakeAudio.duration = 120 + fakeAudio.dispatchEvent(new Event('loadedmetadata')) + music.seek(42) + + expect(music.duration).toBe(120) + expect(fakeAudio.currentTime).toBe(42) + expect(music.currentTime).toBe(42) + + music.seek(999) + expect(fakeAudio.currentTime).toBe(120) + + music.seek(-10) + expect(fakeAudio.currentTime).toBe(0) + }) + + it('revokes the active Blob URL when playback stops', async () => { + fetchMock.mockResolvedValue(audioResponse()) + const music = useMusicStore() + const serverTrack = track('cleanup') + await music.play(serverTrack, [serverTrack]) + + music.stop() + + expect(revokeObjectUrlMock).toHaveBeenCalledOnce() + expect(revokeObjectUrlMock).toHaveBeenCalledWith('blob:sky-music') + expect(fakeAudio.src).toBe('') + }) + + it('does not let a stale audio request replace a newer track', async () => { + let resolveFirstRequest: ((response: Response) => void) | undefined + fetchMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRequest = resolve + }), + ) + .mockResolvedValueOnce(audioResponse([4, 5, 6])) + const music = useMusicStore() + const firstTrack = track('first') + const secondTrack = track('second') + + const firstPlay = music.play(firstTrack, [firstTrack, secondTrack]) + await music.play(secondTrack, [firstTrack, secondTrack]) + resolveFirstRequest?.(audioResponse()) + await firstPlay + + expect(music.currentTrack?.id).toBe('second') + expect(fakeAudio.src).toBe('blob:sky-music') + 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/music.ts b/frontend/src/stores/music.ts new file mode 100644 index 0000000..754b4cc --- /dev/null +++ b/frontend/src/stores/music.ts @@ -0,0 +1,570 @@ +import { defineStore } from 'pinia' + +import type { + MusicBootstrap, + MusicPlaylist, + MusicPlaylistEntry, + MusicTrack, +} from '@/types/music' +import { nuiCall } from '@/utils/nui' + +type YouTubePlayer = { + destroy: () => void + getCurrentTime: () => number + getDuration: () => number + loadVideoById: (videoId: string) => void + pauseVideo: () => void + playVideo: () => void + seekTo: (seconds: number, allowSeekAhead: boolean) => void + setVolume: (volume: number) => void +} + +type YouTubeApi = { + Player: new ( + target: HTMLElement, + options: { + events: { + onError: () => void + onReady: (event: { target: YouTubePlayer }) => void + onStateChange: (event: { data: number }) => void + } + height: string + playerVars: Record + videoId: string + width: string + }, + ) => YouTubePlayer + PlayerState: { + ENDED: number + PAUSED: number + PLAYING: number + } +} + +declare global { + interface Window { + YT?: YouTubeApi + onYouTubeIframeAPIReady?: () => void + } +} + +const audio = new Audio() +audio.preload = 'auto' +const YOUTUBE_API_TIMEOUT_MS = 12000 +let audioBound = false +let audioLoadController: AbortController | null = null +let audioObjectUrl: string | null = null +let playbackGeneration = 0 +let youtubeApiPromise: Promise | null = null +let youtubePlayer: YouTubePlayer | null = null +let youtubeProgressTimer: number | null = null + +export function musicTrackKey( + track: Pick, +): string { + return `${track.source}:${track.id}` +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +function resetAudioSource(): void { + audioLoadController?.abort() + audioLoadController = null + audio.pause() + audio.removeAttribute('src') + audio.load() + if (audioObjectUrl) { + URL.revokeObjectURL(audioObjectUrl) + audioObjectUrl = null + } +} + +function serverAudioMimeType(url: URL): string { + return url.pathname.toLowerCase().endsWith('.ogg') + ? 'audio/ogg' + : 'audio/mpeg' +} + +async function loadServerAudioSource(source: string): Promise { + const resolvedUrl = new URL(source, window.location.href) + const isCfxAsset = + resolvedUrl.protocol === 'https:' && + resolvedUrl.hostname.startsWith('cfx-nui-') + + if (!isCfxAsset) { + audio.src = resolvedUrl.href + audio.load() + return true + } + + const controller = new AbortController() + audioLoadController = controller + try { + const response = await fetch(resolvedUrl.href, { + signal: controller.signal, + }) + if (!response.ok) { + throw new Error(`Music asset request failed (${response.status}).`) + } + + const bytes = await response.arrayBuffer() + if (controller.signal.aborted || audioLoadController !== controller) + return false + + const objectUrl = URL.createObjectURL( + new Blob([bytes], { type: serverAudioMimeType(resolvedUrl) }), + ) + if (controller.signal.aborted || audioLoadController !== controller) { + URL.revokeObjectURL(objectUrl) + return false + } + + audioObjectUrl = objectUrl + audio.src = objectUrl + audio.load() + return true + } finally { + if (audioLoadController === controller) audioLoadController = null + } +} + +function currentAudioDuration(): number { + if (Number.isFinite(audio.duration) && audio.duration > 0) + return audio.duration + if (audio.seekable.length) { + const end = audio.seekable.end(audio.seekable.length - 1) + if (Number.isFinite(end) && end > 0) return end + } + return 0 +} + +function syncAudioDuration(): void { + const store = useMusicStore() + if (store.currentTrack?.source === 'server') { + store.duration = currentAudioDuration() + } +} + +function stopYoutubeProgress(): void { + if (youtubeProgressTimer !== null) { + window.clearInterval(youtubeProgressTimer) + youtubeProgressTimer = null + } +} + +function startYoutubeProgress(): void { + stopYoutubeProgress() + youtubeProgressTimer = window.setInterval(() => { + const store = useMusicStore() + if (!youtubePlayer || store.currentTrack?.source !== 'youtube') return + store.currentTime = Math.max(0, youtubePlayer.getCurrentTime() || 0) + store.duration = Math.max(0, youtubePlayer.getDuration() || 0) + }, 500) +} + +function bindAudioEvents(): void { + if (audioBound) return + audioBound = true + audio.addEventListener('durationchange', syncAudioDuration) + audio.addEventListener('loadedmetadata', syncAudioDuration) + audio.addEventListener('canplay', syncAudioDuration) + audio.addEventListener('progress', syncAudioDuration) + audio.addEventListener('timeupdate', () => { + const store = useMusicStore() + if (store.currentTrack?.source === 'server') { + syncAudioDuration() + store.currentTime = audio.currentTime + } + }) + audio.addEventListener('play', () => { + const store = useMusicStore() + if (store.currentTrack?.source === 'server') store.isPlaying = true + }) + audio.addEventListener('pause', () => { + const store = useMusicStore() + if (store.currentTrack?.source === 'server') store.isPlaying = false + }) + audio.addEventListener('ended', () => { + const store = useMusicStore() + if (store.currentTrack?.source === 'server') void store.next() + }) + audio.addEventListener('error', () => { + const store = useMusicStore() + if (store.currentTrack?.source === 'server') { + store.isPlaying = false + store.playbackError = 'playback_failed' + } + }) +} + +function loadYouTubeApi(): Promise { + if (window.YT?.Player) return Promise.resolve(window.YT) + if (youtubeApiPromise) return youtubeApiPromise + youtubeApiPromise = new Promise((resolve, reject) => { + let settled = false + const previousReady = window.onYouTubeIframeAPIReady + let script = document.querySelector( + 'script[data-sky-phone-youtube-api]', + ) + let timeout = 0 + + const cleanup = (): void => { + window.clearTimeout(timeout) + script?.removeEventListener('error', handleError) + } + const fail = (error: Error): void => { + if (settled) return + settled = true + cleanup() + if (!window.YT?.Player) script?.remove() + if (window.onYouTubeIframeAPIReady === handleReady) + window.onYouTubeIframeAPIReady = previousReady + youtubeApiPromise = null + reject(error) + } + const handleReady = (): void => { + try { + previousReady?.() + } catch (error) { + console.error('[Music] A previous YouTube callback failed:', error) + } + if (!window.YT?.Player) { + fail(new Error('YouTube player API did not initialize.')) + return + } + if (settled) return + settled = true + cleanup() + resolve(window.YT) + } + function handleError(): void { + fail(new Error('YouTube player API failed to load.')) + } + + window.onYouTubeIframeAPIReady = handleReady + if (!script) { + script = document.createElement('script') + script.dataset.skyPhoneYoutubeApi = 'true' + script.src = 'https://www.youtube.com/iframe_api' + script.async = true + document.head.append(script) + } + script.addEventListener('error', handleError, { once: true }) + timeout = window.setTimeout( + () => fail(new Error('YouTube player API timed out.')), + YOUTUBE_API_TIMEOUT_MS, + ) + }) + return youtubeApiPromise +} + +function resetYoutubePlayer(): void { + stopYoutubeProgress() + const player = youtubePlayer + youtubePlayer = null + try { + player?.destroy() + } catch (error) { + console.error('[Music] YouTube player cleanup failed:', error) + } + document.getElementById('sky-phone-youtube-player')?.remove() +} + +async function loadYouTubeTrack(videoId: string): Promise { + const api = await loadYouTubeApi() + const store = useMusicStore() + if (youtubePlayer) { + youtubePlayer.setVolume(store.volume * 100) + youtubePlayer.loadVideoById(videoId) + youtubePlayer.playVideo() + startYoutubeProgress() + return + } + + const host = document.createElement('div') + host.id = 'sky-phone-youtube-player' + host.style.position = 'fixed' + host.style.left = '-10000px' + host.style.top = '0' + host.style.width = '200px' + host.style.height = '200px' + host.style.pointerEvents = 'none' + document.body.append(host) + + await new Promise((resolve, reject) => { + youtubePlayer = new api.Player(host, { + height: '200', + width: '200', + videoId, + playerVars: { + autoplay: 1, + controls: 0, + disablekb: 1, + fs: 0, + origin: window.location.origin, + playsinline: 1, + rel: 0, + }, + events: { + onError: () => { + const activeStore = useMusicStore() + if (activeStore.currentTrack?.source === 'youtube') { + activeStore.isPlaying = false + activeStore.playbackError = 'playback_failed' + } + resetYoutubePlayer() + reject(new Error('YouTube player rejected the track.')) + }, + onReady: (event) => { + youtubePlayer = event.target + event.target.setVolume(useMusicStore().volume * 100) + event.target.playVideo() + startYoutubeProgress() + resolve() + }, + onStateChange: (event) => { + const activeStore = useMusicStore() + if (activeStore.currentTrack?.source !== 'youtube') return + if (event.data === api.PlayerState.PLAYING) { + activeStore.isPlaying = true + activeStore.playbackError = '' + startYoutubeProgress() + } else if (event.data === api.PlayerState.PAUSED) { + activeStore.isPlaying = false + } else if (event.data === api.PlayerState.ENDED) { + activeStore.isPlaying = false + void activeStore.next() + } + }, + }, + }) + }) +} + +function stopActiveMedia(): void { + playbackGeneration += 1 + resetAudioSource() + youtubePlayer?.pauseVideo() + stopYoutubeProgress() +} + +export const useMusicStore = defineStore('music', { + state: () => ({ + currentTime: 0, + currentTrack: null as MusicTrack | null, + duration: 0, + error: '', + isLoading: false, + isPlaying: false, + playbackError: '', + playlists: [] as MusicPlaylist[], + queue: [] as MusicTrack[], + queueIndex: -1, + serverTracks: [] as MusicTrack[], + volume: 0.72, + youtubeTracks: [] as MusicTrack[], + }), + getters: { + allTracks(state): MusicTrack[] { + return [...state.serverTracks, ...state.youtubeTracks] + }, + }, + actions: { + applyBootstrap(data: MusicBootstrap): void { + this.serverTracks = data.serverTracks + this.youtubeTracks = data.youtubeTracks + this.playlists = data.playlists + const tracks = new Map( + [...data.serverTracks, ...data.youtubeTracks].map((track) => [ + musicTrackKey(track), + track, + ]), + ) + if (this.currentTrack) { + const current = tracks.get(musicTrackKey(this.currentTrack)) + if (current) this.currentTrack = current + else this.stop() + } + this.queue = this.queue + .map((track) => tracks.get(musicTrackKey(track))) + .filter((track): track is MusicTrack => Boolean(track)) + this.queueIndex = this.currentTrack + ? this.queue.findIndex( + (track) => + musicTrackKey(track) === musicTrackKey(this.currentTrack!), + ) + : -1 + }, + async request( + endpoint: string, + payload: Record, + ): Promise { + this.isLoading = true + const response = await nuiCall(endpoint, payload) + this.isLoading = false + this.error = response.success ? '' : (response.error ?? 'default') + if (response.success && response.data) this.applyBootstrap(response.data) + return response.success + }, + async load(): Promise { + void loadYouTubeApi().catch((error) => { + console.warn('[Music] YouTube API preload failed:', error) + }) + return this.request('music:bootstrap', {}) + }, + async addYouTube(url: string, title = '', artist = ''): Promise { + return this.request('music:add-youtube', { artist, title, url }) + }, + async removeYouTube(id: string): Promise { + return this.request('music:remove-youtube', { id }) + }, + async createPlaylist(name: string): Promise { + return this.request('music:create-playlist', { name }) + }, + async renamePlaylist(id: string, name: string): Promise { + return this.request('music:rename-playlist', { id, name }) + }, + async deletePlaylist(id: string): Promise { + return this.request('music:delete-playlist', { id }) + }, + async addToPlaylist( + playlistId: string, + track: MusicTrack, + ): Promise { + return this.request('music:add-to-playlist', { + playlistId, + songId: track.id, + source: track.source, + }) + }, + async removeFromPlaylist( + playlistId: string, + track: MusicTrack, + ): Promise { + return this.request('music:remove-from-playlist', { + playlistId, + songId: track.id, + source: track.source, + }) + }, + trackForEntry(entry: MusicPlaylistEntry): MusicTrack | undefined { + return this.allTracks.find( + (track) => track.source === entry.source && track.id === entry.songId, + ) + }, + tracksForPlaylist(playlist: MusicPlaylist): MusicTrack[] { + return playlist.entries + .map((entry) => this.trackForEntry(entry)) + .filter((track): track is MusicTrack => Boolean(track)) + }, + async play(track: MusicTrack, queue?: MusicTrack[]): Promise { + bindAudioEvents() + stopActiveMedia() + const requestedGeneration = playbackGeneration + const playableQueue = queue?.length ? [...queue] : [...this.allTracks] + if (!playableQueue.length) playableQueue.push(track) + const index = playableQueue.findIndex( + (candidate) => musicTrackKey(candidate) === musicTrackKey(track), + ) + this.queue = playableQueue + this.queueIndex = index >= 0 ? index : 0 + this.currentTrack = index >= 0 ? playableQueue[index]! : track + this.currentTime = 0 + this.duration = 0 + this.isPlaying = false + this.playbackError = '' + try { + if (track.source === 'server' && track.url) { + const loaded = await loadServerAudioSource(track.url) + if (!loaded || requestedGeneration !== playbackGeneration) return + audio.volume = this.volume + await audio.play() + } else if (track.source === 'youtube' && track.videoId) { + await loadYouTubeTrack(track.videoId) + } else { + throw new Error('Track has no playable source.') + } + } catch (error) { + if (requestedGeneration !== playbackGeneration || isAbortError(error)) + return + console.error('[Music] Playback failed:', error) + this.isPlaying = false + this.playbackError = 'playback_failed' + } + }, + async toggle(): Promise { + if (!this.currentTrack) { + const first = this.queue[0] ?? this.allTracks[0] + if (first) + await this.play( + first, + this.queue.length ? this.queue : this.allTracks, + ) + return + } + if (this.isPlaying) { + if (this.currentTrack.source === 'server') audio.pause() + else youtubePlayer?.pauseVideo() + this.isPlaying = false + return + } + try { + if (this.currentTrack.source === 'server') await audio.play() + else youtubePlayer?.playVideo() + this.isPlaying = true + } catch (error) { + console.error('[Music] Resume failed:', error) + this.playbackError = 'playback_failed' + } + }, + async next(): Promise { + if (!this.queue.length) return + const index = + this.queueIndex < 0 ? 0 : (this.queueIndex + 1) % this.queue.length + await this.play(this.queue[index]!, this.queue) + }, + async previous(): Promise { + if (!this.queue.length) return + if (this.currentTime > 3) { + this.seek(0) + return + } + const index = + this.queueIndex <= 0 ? this.queue.length - 1 : this.queueIndex - 1 + await this.play(this.queue[index]!, this.queue) + }, + seek(seconds: number): void { + if (!Number.isFinite(seconds)) return + const duration = this.duration || currentAudioDuration() + const next = Math.max(0, Math.min(duration || seconds, seconds)) + if (this.currentTrack?.source === 'server') { + try { + audio.currentTime = next + this.currentTime = next + } catch (error) { + console.warn('[Music] Audio seek failed:', error) + } + } else { + youtubePlayer?.seekTo(next, true) + this.currentTime = next + } + }, + setVolume(value: number): void { + this.volume = Math.max(0, Math.min(1, value)) + audio.volume = this.volume + youtubePlayer?.setVolume(this.volume * 100) + }, + stop(): void { + stopActiveMedia() + this.currentTrack = null + this.currentTime = 0 + this.duration = 0 + this.isPlaying = false + this.playbackError = '' + this.queue = [] + this.queueIndex = -1 + }, + }, +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index bbb767d..82099e3 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -2128,6 +2128,95 @@ const defaultLocales: LocaleTree = { default: 'The mail request failed.', }, }, + music: { + name: 'Music', + loading: 'Loading Music...', + navigation: 'Music navigation', + tabs: { library: 'Library', playlists: 'Playlists', search: 'Search' }, + librarySubtitle: 'Your music, all in one place.', + playlistsSubtitle: 'Mix server tracks and your YouTube favorites.', + featured: 'Made for the city', + recentlyAdded: 'Recently Added', + songs: 'Songs', + play: 'Play', + previous: 'Previous track', + next: 'Next track', + nowPlaying: 'Now Playing', + progress: 'Playback position', + volume: 'Music volume', + addMusic: 'Add music', + addYouTube: 'Add from YouTube', + youtubeBody: + 'Paste a public YouTube link. Leave the optional fields empty to use the video title and channel automatically.', + youtubeUrl: 'YouTube URL', + youtubePlaceholder: 'https://youtube.com/watch?v=...', + youtubeTitle: 'Song title (optional)', + 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', + 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.', + 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', + emptyLibrary: 'Your Library is Empty', + emptyLibraryBody: + 'Add a YouTube song or ask the server owner to publish MP3 and OGG tracks.', + emptyPlaylist: 'No Songs Yet', + emptyPlaylistBody: 'Add songs from your library to this playlist.', + noPlaylists: 'No Playlists', + noPlaylistsBody: 'Create a playlist for your favorite songs.', + searchPlaceholder: 'Artists, Songs and Playlists', + noResults: 'No Results', + noResultsBody: 'Try another song title or artist.', + errors: { + invalid_youtube_url: 'Paste a valid public YouTube video link.', + invalid_song_metadata: 'Use a shorter song title or artist name.', + song_exists: 'That YouTube song is already in your library.', + 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.', + 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.', + }, + }, notes: { name: 'Notes', note: 'Note', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 732dcf9..dbb80ae 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -16,6 +16,7 @@ export type PhoneAppId = | 'map' | 'notes' | 'radio' + | 'music' | 'photos' | 'app-store' | 'settings' diff --git a/frontend/src/types/music.ts b/frontend/src/types/music.ts new file mode 100644 index 0000000..0716601 --- /dev/null +++ b/frontend/src/types/music.ts @@ -0,0 +1,30 @@ +export type MusicSource = 'server' | 'youtube' + +export type MusicTrack = { + artist: string + artwork: string | null + createdAt?: number + id: string + source: MusicSource + title: string + url?: string + videoId?: string +} + +export type MusicPlaylistEntry = { + songId: string + source: MusicSource +} + +export type MusicPlaylist = { + createdAt: number + entries: MusicPlaylistEntry[] + id: string + name: string +} + +export type MusicBootstrap = { + playlists: MusicPlaylist[] + serverTracks: MusicTrack[] + youtubeTracks: MusicTrack[] +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 86a2c67..77d9600 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -95,6 +95,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< map: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true }, radio: { enabled: true, sounds: true }, + music: { enabled: true, sounds: true }, photos: { enabled: true, sounds: true }, settings: { enabled: true, sounds: true }, } diff --git a/frontend/src/views/apps/MusicApp.vue b/frontend/src/views/apps/MusicApp.vue new file mode 100644 index 0000000..9ba20b8 --- /dev/null +++ b/frontend/src/views/apps/MusicApp.vue @@ -0,0 +1,2023 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index e2a1648..a482c22 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -48,6 +48,89 @@ const radioData = { settings: { autoRejoin: false, notifications: true }, volume: 50, } +let musicSequence = 3 +const musicServerTracks = [ + { + id: 'city-after-dark', + source: 'server', + title: 'City After Dark', + artist: 'Sky Records', + url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga', + artwork: 'https://picsum.photos/seed/sky-music-city/600/600', + }, + { + id: 'pacific-drive', + source: 'server', + title: 'Pacific Drive', + artist: 'Vespucci FM', + url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga', + artwork: 'https://picsum.photos/seed/sky-music-pacific/600/600', + }, + { + id: 'neon-rain', + source: 'server', + title: 'Neon Rain', + artist: 'Mirror Park', + url: 'https://media.w3.org/2010/07/bunny/04-Death_Becomes_Fur.oga', + artwork: 'https://picsum.photos/seed/sky-music-neon/600/600', + }, +] +let musicYoutubeTracks = [ + { + id: 'music-youtube-1', + source: 'youtube', + videoId: 'dQw4w9WgXcQ', + title: 'Never Gonna Give You Up', + artist: 'Rick Astley', + artwork: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', + createdAt: Date.now() - 120000, + }, +] +let musicPlaylists = [ + { + id: 'music-playlist-1', + name: 'Night Ride', + createdAt: Date.now() - 86400000, + entries: [ + { source: 'server', songId: 'city-after-dark' }, + { source: 'server', songId: 'neon-rain' }, + { source: 'youtube', songId: 'music-youtube-1' }, + ], + }, +] + +function musicBootstrap() { + return { + serverTracks: musicServerTracks, + youtubeTracks: musicYoutubeTracks, + playlists: musicPlaylists, + } +} + +async function fetchYoutubeMetadata(videoId) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 4500) + try { + const watchUrl = `https://www.youtube.com/watch?v=${videoId}` + const response = await fetch( + `https://www.youtube.com/oembed?format=json&url=${encodeURIComponent(watchUrl)}`, + { + headers: { Accept: 'application/json' }, + signal: controller.signal, + }, + ) + if (!response.ok) return null + const data = await response.json() + const title = typeof data.title === 'string' ? data.title.trim() : '' + const artist = + typeof data.author_name === 'string' ? data.author_name.trim() : '' + return title && artist ? { artist, title } : null + } catch { + return null + } finally { + clearTimeout(timeout) + } +} let mockBankBalance = 24787 let mockCashBalance = 2350 let nextBankTransactionId = 7 @@ -1693,9 +1776,133 @@ function flareBootstrap() { } } -app.post('/api/:endpoint', (request, response) => { +app.post('/api/:endpoint', async (request, response) => { console.log(`[NUI] ${request.params.endpoint}`, request.body) const endpoint = request.params.endpoint + if (endpoint === 'music:bootstrap') { + response.json({ success: true, data: musicBootstrap() }) + return + } + if (endpoint === 'music:add-youtube') { + const value = String(request.body.url ?? '') + const customTitle = String(request.body.title ?? '').trim() + const customArtist = String(request.body.artist ?? '').trim() + const videoId = + value.match(/[?&]v=([\w-]{11})/)?.[1] ?? + value.match(/youtu\.be\/([\w-]{11})/)?.[1] + if (!videoId) { + response.json({ success: false, error: 'invalid_youtube_url' }) + return + } + if (customTitle.length > 160 || customArtist.length > 120) { + response.json({ success: false, error: 'invalid_song_metadata' }) + return + } + if (musicYoutubeTracks.some((track) => track.videoId === videoId)) { + response.json({ success: false, error: 'song_exists' }) + return + } + const metadata = + !customTitle || !customArtist ? await fetchYoutubeMetadata(videoId) : null + musicYoutubeTracks.unshift({ + id: `music-youtube-${musicSequence++}`, + source: 'youtube', + videoId, + title: customTitle || metadata?.title || `YouTube ${videoId}`, + artist: customArtist || metadata?.artist || 'YouTube', + artwork: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`, + createdAt: Date.now(), + }) + response.json({ success: true, data: musicBootstrap() }) + return + } + if (endpoint === 'music:remove-youtube') { + musicYoutubeTracks = musicYoutubeTracks.filter( + (track) => track.id !== request.body.id, + ) + musicPlaylists.forEach((playlist) => { + playlist.entries = playlist.entries.filter( + (entry) => + !(entry.source === 'youtube' && entry.songId === request.body.id), + ) + }) + response.json({ success: true, data: musicBootstrap() }) + return + } + if (endpoint === 'music:create-playlist') { + const name = String(request.body.name ?? '').trim() + if (!name) { + response.json({ success: false, error: 'invalid_playlist' }) + return + } + musicPlaylists.unshift({ + id: `music-playlist-${musicSequence++}`, + name, + createdAt: Date.now(), + entries: [], + }) + response.json({ success: true, data: musicBootstrap() }) + return + } + if (endpoint === 'music:rename-playlist') { + const playlist = musicPlaylists.find((item) => item.id === request.body.id) + if (!playlist) { + response.json({ success: false, error: 'playlist_not_found' }) + return + } + playlist.name = String(request.body.name ?? '').trim() + response.json({ success: true, data: musicBootstrap() }) + return + } + if (endpoint === 'music:delete-playlist') { + musicPlaylists = musicPlaylists.filter( + (playlist) => playlist.id !== request.body.id, + ) + response.json({ success: true, data: musicBootstrap() }) + return + } + if (endpoint === 'music:add-to-playlist') { + const playlist = musicPlaylists.find( + (item) => item.id === request.body.playlistId, + ) + if (!playlist) { + response.json({ success: false, error: 'playlist_not_found' }) + return + } + if ( + playlist.entries.some( + (entry) => + entry.source === request.body.source && + entry.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 + } + if (endpoint === 'music:remove-from-playlist') { + const playlist = musicPlaylists.find( + (item) => item.id === request.body.playlistId, + ) + if (playlist) { + playlist.entries = playlist.entries.filter( + (entry) => + entry.source !== request.body.source || + entry.songId !== request.body.songId, + ) + } + response.json({ success: true, data: musicBootstrap() }) + return + } if (endpoint === 'radio:get') { response.json({ success: true, data: radioData }) return diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f20ba6c..298a056 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -33,7 +33,8 @@ export default defineConfig({ allow: [fileURLToPath(new URL('.', import.meta.url))], strict: false, }, - host: '127.0.0.1', + // YouTube rejects otherwise valid embeds when the dev page uses the + // numeric loopback origin. Keep the local UI on the localhost origin. + host: 'localhost', }, }) - diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 9f61f80..319a2b9 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -813,6 +813,42 @@ Locales["en"] = { fog = "Reduced visibility in low-lying areas.", snow = "Cold conditions with snow across the region.", }, }, + music = { + name = "Music", loading = "Loading Music...", navigation = "Music navigation", + tabs = { library = "Library", playlists = "Playlists", search = "Search" }, + librarySubtitle = "Your music, all in one place.", playlistsSubtitle = "Mix server tracks and your YouTube favorites.", + featured = "Made for the city", recentlyAdded = "Recently Added", songs = "Songs", play = "Play", + previous = "Previous track", next = "Next track", nowPlaying = "Now Playing", progress = "Playback position", volume = "Music volume", + addMusic = "Add music", addYouTube = "Add from YouTube", youtubeBody = "Paste a public YouTube link. Leave the optional fields empty to use the video title and channel automatically.", + youtubeUrl = "YouTube URL", youtubePlaceholder = "https://youtube.com/watch?v=...", youtubeTitle = "Song title (optional)", + 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", 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.", 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", + emptyLibrary = "Your Library is Empty", emptyLibraryBody = "Add a YouTube song or ask the server owner to publish MP3 and OGG tracks.", + emptyPlaylist = "No Songs Yet", emptyPlaylistBody = "Add songs from your library to this playlist.", + noPlaylists = "No Playlists", noPlaylistsBody = "Create a playlist for your favorite songs.", + searchPlaceholder = "Artists, Songs and Playlists", noResults = "No Results", noResultsBody = "Try another song title or artist.", + errors = { + invalid_youtube_url = "Paste a valid public YouTube video link.", invalid_song_metadata = "Use a shorter song title or artist name.", + song_exists = "That YouTube song is already in your library.", + 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.", 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.", + }, + }, notes = { name = "Notes", note = "Note", back = "Back", actions = "Note actions", newNote = "New Note", searchPlaceholder = "Search Notes", diff --git a/sky_phone/config/music.lua b/sky_phone/config/music.lua new file mode 100644 index 0000000..734848b --- /dev/null +++ b/sky_phone/config/music.lua @@ -0,0 +1,19 @@ +Config.Music = { + -- Files are found recursively in config/music by their Id. For example, + -- Id = "night-drive" finds night-drive.ogg/mp3 and optional artwork with + -- the same name (.webp/.png/.jpg/.jpeg), even inside subdirectories. + Tracks = { + -- { + -- Id = "night-drive", + -- Title = "Night Drive", + -- Artist = "Sky Records", + -- }, + }, + + MaximumPersonalSongs = 100, + MaximumPlaylists = 50, + MaximumPlaylistSongs = 250, + PlaylistNameMaxLength = 80, + ActionsPerMinute = 30, + MetadataTimeoutMs = 4500, +} diff --git a/sky_phone/config/music/README.txt b/sky_phone/config/music/README.txt new file mode 100644 index 0000000..db3d7e9 --- /dev/null +++ b/sky_phone/config/music/README.txt @@ -0,0 +1,28 @@ +Place server-owned .mp3 and .ogg files in this directory. + +In ../music.lua, register only Id, Title and Artist. The resource searches this +directory and every subdirectory for files whose name matches the Id. Example: + + Tracks = { + { Id = "night-drive", Title = "Night Drive", Artist = "Sky Records" }, + } + +The entry above finds, for example: + + config/music/electronic/night-drive.ogg + config/music/electronic/covers/night-drive.webp + +Audio must be named .ogg or .mp3. Artwork is optional and must be named +.webp, .png, .jpg or .jpeg. If multiple formats exist, OGG is +preferred over MP3; artwork priority is WEBP, PNG, JPG, then JPEG. Do not place +the same preferred extension for one Id in multiple folders. + +Matching is case-insensitive, but keep Id spelling stable because saved +playlists reference it. Folder names may contain spaces, but must not contain +path separators or control characters. Do not give a folder an audio or artwork +extension. Restart the sky_phone resource after changing tracks or files; a +frontend build is not required. + +Upgrading from the old path-based setup: rename each audio and artwork file to +its existing Id, then remove File and Artwork from the track entry. Do not +change the Id itself. diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 69b2434..f248c62 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -40,6 +40,7 @@ server_scripts { '@oxmysql/lib/MySQL.lua', 'config/config.lua', 'config/media.lua', + 'config/music.lua', 'config/locales/*.lua', 'source/bridge/server/database.lua', 'source/bridge/server/migrations.lua', @@ -70,6 +71,7 @@ server_scripts { 'source/server/map.lua', 'source/server/skyride.lua', 'source/server/calendar.lua', + 'source/server/music.lua', 'source/server/radio.lua', } @@ -77,6 +79,7 @@ files { 'source/html/index.html', 'source/html/assets/**', 'source/html/img/**', + 'config/music/**', } ui_page 'source/html/index.html' diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 586d795..2e4ccd4 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -116,6 +116,14 @@ local server_callbacks = { "calendar:create", "calendar:update", "calendar:delete", + "music:bootstrap", + "music:add-youtube", + "music:remove-youtube", + "music:create-playlist", + "music:rename-playlist", + "music:delete-playlist", + "music:add-to-playlist", + "music:remove-from-playlist", "map:markers", "map:create-marker", "map:delete-marker", diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 5250e10..7205772 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -735,6 +735,75 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_music_youtube_songs", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NULL" }, + { name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "video_id", type = "CHAR(11) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "title", type = "VARCHAR(160) NOT NULL" }, + { name = "artist", type = "VARCHAR(120) NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_music_youtube_account", columns = "(`account_id`, `video_id`)" }, + { name = "uniq_sky_phone_music_youtube_device", columns = "(`device_imei`, `video_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_music_youtube_account", columns = "(`account_id`, `created_at`)" }, + { name = "idx_sky_phone_music_youtube_device", columns = "(`device_imei`, `created_at`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_music_playlists", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "account_id", type = "BIGINT UNSIGNED NULL" }, + { name = "device_imei", type = "CHAR(15) NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "name", type = "VARCHAR(80) NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { name = "updated_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + indexes = { + { name = "idx_sky_phone_music_playlists_account", columns = "(`account_id`, `updated_at`)" }, + { name = "idx_sky_phone_music_playlists_device", columns = "(`device_imei`, `updated_at`)" }, + }, + foreignKeys = { + { column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" }, + { column = "device_imei", references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_music_playlist_items", + columns = { + { name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" }, + { name = "playlist_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "source", type = "ENUM('server', 'youtube') NOT NULL" }, + { name = "song_id", type = "VARCHAR(64) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { name = "position", type = "SMALLINT UNSIGNED NOT NULL" }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_music_playlist_song", columns = "(`playlist_id`, `source`, `song_id`)" }, + }, + indexes = { + { name = "idx_sky_phone_music_playlist_order", columns = "(`playlist_id`, `position`, `id`)" }, + }, + foreignKeys = { + { column = "playlist_id", references = "`sky_phone_music_playlists` (`id`) ON DELETE CASCADE" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_radio_profiles", columns = { diff --git a/sky_phone/source/server/music.lua b/sky_phone/source/server/music.lua new file mode 100644 index 0000000..63eba59 --- /dev/null +++ b/sky_phone/source/server/music.lua @@ -0,0 +1,787 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local server_tracks = {} +local server_tracks_by_id = {} +local configured_track_ids = {} +local music_asset_directory = "config/music" +local music_asset_prefix = music_asset_directory .. "/" +local music_asset_extensions = { + audio = { "ogg", "mp3" }, + artwork = { "webp", "png", "jpg", "jpeg" }, +} +local music_asset_types = {} + +for kind, extensions in pairs(music_asset_extensions) do + for _, extension in ipairs(extensions) do + music_asset_types[extension] = kind + end +end + +local function trim(value) + return type(value) == "string" and value:match("^%s*(.-)%s*$") or nil +end + +local function text_length(value) + return type(value) == "string" and utf8.len(value) or nil +end + +local function truncate_text(value, maximum) + local length = text_length(value) + if not length or length <= maximum then + return value + end + local boundary = utf8.offset(value, maximum + 1) + return boundary and value:sub(1, boundary - 1) or value +end + +local function optional_text(value) + local normalized = trim(value) + return normalized ~= "" and normalized or nil +end + +local function safe_music_asset_name(name) + return type(name) == "string" + and name ~= "" + and name ~= "." + and name ~= ".." + and not name:find("[/\\]") + and not name:find("%c") +end + +local function add_music_asset(index, relative_path, file_name) + local stem, extension = file_name:match("^(.+)%.([^.]+)$") + extension = extension and extension:lower() or nil + local kind = extension and music_asset_types[extension] or nil + if not stem or not kind then + return + end + + local id = stem:lower() + local by_id = index[kind][id] + if not by_id then + by_id = {} + index[kind][id] = by_id + end + + local candidates = by_id[extension] + if not candidates then + candidates = {} + by_id[extension] = candidates + end + candidates[#candidates + 1] = music_asset_prefix .. relative_path +end + +local function discover_music_assets() + local index = { audio = {}, artwork = {} } + if type(io.readdir) ~= "function" then + Bridge.Debug( + "error", + "[sky_phone] Automatic music discovery requires io.readdir. Update the FXServer artifacts.", + { always = true } + ) + return nil + end + + local mount_root = ("@%s/%s"):format(GetCurrentResourceName(), music_asset_directory) + local visited_entries = 0 + local failure_reason = nil + + local function walk(mount_path, relative_directory, depth) + if failure_reason then + return true + end + if depth > 32 then + failure_reason = "directory nesting exceeds 32 levels" + return true + end + + local directory = io.readdir(mount_path) + if not directory then + failure_reason = ("could not read '%s'"):format(mount_path) + return true + end + + local names = {} + local has_entries = false + for name in directory:lines() do + has_entries = true + if safe_music_asset_name(name) then + names[#names + 1] = name + else + Bridge.Debug( + "warn", + "[sky_phone] Skipped unsafe music path entry '%s'.", + tostring(name), + { always = true } + ) + end + end + directory:close() + table.sort(names) + + for _, name in ipairs(names) do + if visited_entries >= 10000 then + failure_reason = "more than 10000 files and directories were found" + return true + end + visited_entries = visited_entries + 1 + local relative_path = relative_directory == "" + and name + or (relative_directory .. "/" .. name) + local asset_path = mount_path .. "/" .. name + + -- Cfx returns an empty directory listing for regular files. Walking every + -- entry is portable; io.open cannot distinguish directories on Linux. + if not walk(asset_path, relative_path, depth + 1) then + add_music_asset(index, relative_path, name) + end + end + + return has_entries + end + + local success, scan_error = pcall(walk, mount_root, "", 0) + if not success then + failure_reason = tostring(scan_error) + end + if failure_reason then + Bridge.Debug( + "error", + "[sky_phone] Music asset discovery failed; no server tracks were loaded: %s", + failure_reason, + { always = true } + ) + return nil + end + + return index +end + +local function resolve_music_asset(index, id, kind) + local by_extension = index[kind][id:lower()] + if not by_extension then + return nil, "missing" + end + + for _, extension in ipairs(music_asset_extensions[kind]) do + local candidates = by_extension[extension] + if candidates then + table.sort(candidates) + if #candidates > 1 then + return nil, "ambiguous", candidates + end + return candidates[1] + end + end + + return nil, "missing" +end + +local function music_asset_url(path) + local encoded_path = path:gsub("([^A-Za-z0-9%-%._~/])", function(character) + return ("%%%02X"):format(character:byte()) + end) + return ("https://cfx-nui-%s/%s"):format(GetCurrentResourceName(), encoded_path) +end + +local function affected_rows(result) + if type(result) == "number" then + return result + end + 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 + if type(id) ~= "string" then + error("[sky_phone] Database did not generate a music id.") + end + return id +end + +local function normalize_server_tracks() + local music_assets = discover_music_assets() + if not music_assets then + return + end + + for index, configured in ipairs(Config.Music.Tracks or {}) do + local id = type(configured) == "table" and trim(configured.Id) or nil + local title = type(configured) == "table" and trim(configured.Title) or nil + local artist = type(configured) == "table" and trim(configured.Artist) or nil + local title_length = text_length(title) + local artist_length = text_length(artist) + + if not id + or not id:match("^[A-Za-z0-9_-]+$") + or #id > 48 + or not title_length + or title_length < 1 + or title_length > 160 + or not artist_length + or artist_length < 1 + or artist_length > 120 + then + Bridge.Debug( + "error", + "[sky_phone] Ignored invalid Config.Music.Tracks entry at index %s. Only Id, Title, and Artist are required.", + tostring(index), + { always = true } + ) + elseif configured_track_ids[id:lower()] then + Bridge.Debug( + "error", + "[sky_phone] Ignored duplicate Config.Music.Tracks Id '%s' at index %s. IDs are case-insensitive.", + id, + tostring(index), + { always = true } + ) + else + configured_track_ids[id:lower()] = true + local file, file_error, file_candidates = resolve_music_asset(music_assets, id, "audio") + local artwork, artwork_error, artwork_candidates = resolve_music_asset(music_assets, id, "artwork") + + if file_error == "missing" then + Bridge.Debug( + "error", + "[sky_phone] Ignored music track '%s': no %s.ogg or %s.mp3 was found under config/music.", + id, + id, + id, + { always = true } + ) + elseif file_error == "ambiguous" then + Bridge.Debug( + "error", + "[sky_phone] Ignored music track '%s': multiple equally preferred audio files were found: %s", + id, + table.concat(file_candidates, ", "), + { always = true } + ) + else + if artwork_error == "ambiguous" then + Bridge.Debug( + "warn", + "[sky_phone] Music track '%s' was loaded without artwork because multiple equally preferred images were found: %s", + id, + table.concat(artwork_candidates, ", "), + { always = true } + ) + artwork = nil + end + + local track = { + id = id, + source = "server", + title = title, + artist = artist, + url = music_asset_url(file), + artwork = artwork and music_asset_url(artwork) or nil, + } + server_tracks[#server_tracks + 1] = track + server_tracks_by_id[id] = track + end + end + end +end + +normalize_server_tracks() + +local function session_owner(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, nil, error_response + end + local account = SkyPhone.RequireAccount(source) + return account and account.id or nil, session.imei +end + +local function owner_condition(account_id, imei, alias) + local prefix = alias and ("`" .. alias .. "`.") or "" + if account_id then + return prefix .. "`account_id` = ?", { account_id } + end + return prefix .. "`account_id` IS NULL AND " .. prefix .. "`device_imei` = ?", { imei } +end + +local function append_params(target, values) + for _, value in ipairs(values) do + target[#target + 1] = value + end +end + +local function youtube_track_dto(row) + local video_id = row.video_id + return { + id = row.id, + source = "youtube", + videoId = video_id, + title = row.title, + artist = row.artist, + artwork = "https://i.ytimg.com/vi/" .. video_id .. "/hqdefault.jpg", + createdAt = (tonumber(row.created_at_unix) or 0) * 1000, + } +end + +local function list_youtube_tracks(account_id, imei) + local condition, params = owner_condition(account_id, imei) + local rows = Bridge.Database.Query(([[ + SELECT `id`, `video_id`, `title`, `artist`, + UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_music_youtube_songs` + WHERE %s + ORDER BY `created_at` DESC, `id` + ]]):format(condition), params) + local tracks = {} + for index, row in ipairs(rows) do + tracks[index] = youtube_track_dto(row) + end + return tracks +end + +local function list_playlists(account_id, imei) + local condition, params = owner_condition(account_id, imei) + local rows = Bridge.Database.Query(([[ + SELECT `id`, `name`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_music_playlists` + WHERE %s + ORDER BY `updated_at` DESC, `created_at` DESC + ]]):format(condition), params) + local playlists = {} + local playlists_by_id = {} + for index, row in ipairs(rows) do + local playlist = { + id = row.id, + name = row.name, + entries = {}, + createdAt = (tonumber(row.created_at_unix) or 0) * 1000, + } + playlists[index] = playlist + playlists_by_id[row.id] = playlist + end + + if #rows == 0 then + return playlists + end + + local item_condition, item_params = owner_condition(account_id, imei, "p") + local items = Bridge.Database.Query(([[ + SELECT `i`.`playlist_id`, `i`.`source`, `i`.`song_id` + FROM `sky_phone_music_playlist_items` AS `i` + INNER JOIN `sky_phone_music_playlists` AS `p` ON `p`.`id` = `i`.`playlist_id` + WHERE %s + ORDER BY `i`.`position`, `i`.`id` + ]]):format(item_condition), item_params) + for _, item in ipairs(items) do + local playlist = playlists_by_id[item.playlist_id] + if playlist then + playlist.entries[#playlist.entries + 1] = { + source = item.source, + songId = item.song_id, + } + end + end + return playlists +end + +local function bootstrap(account_id, imei) + return { + serverTracks = server_tracks, + youtubeTracks = list_youtube_tracks(account_id, imei), + playlists = list_playlists(account_id, imei), + } +end + +local function parse_youtube_id(value) + if type(value) ~= "string" or #value > 500 then + return nil + end + local host, path = value:match("^https?://([^/]+)(/.*)$") + if not host or not path then + return nil + end + host = host:lower():gsub(":443$", "") + local id + if host == "youtu.be" or host == "www.youtu.be" then + id = path:match("^/([%w_-]+)") + elseif host == "youtube.com" + or host == "www.youtube.com" + or host == "m.youtube.com" + or host == "music.youtube.com" + or host == "youtube-nocookie.com" + or host == "www.youtube-nocookie.com" + then + id = path:match("[?&]v=([%w_-]+)") + or path:match("^/shorts/([%w_-]+)") + or path:match("^/embed/([%w_-]+)") + or path:match("^/live/([%w_-]+)") + end + return id and #id == 11 and id or nil +end + +local function fetch_youtube_metadata(video_id) + local request = promise.new() + local settled = false + local function resolve(value) + if settled then + return + end + settled = true + request:resolve(value) + end + + PerformHttpRequest( + "https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=" .. video_id, + function(status, body) + if status ~= 200 or type(body) ~= "string" then + resolve(nil) + return + end + local success, decoded = pcall(json.decode, body) + if not success or type(decoded) ~= "table" then + resolve(nil) + return + end + local title = trim(decoded.title) + local artist = trim(decoded.author_name) + if not text_length(title) or not text_length(artist) then + resolve(nil) + return + end + resolve({ + title = truncate_text(title, 160), + artist = truncate_text(artist, 120), + }) + end, + "GET", + "", + { ["Accept"] = "application/json" } + ) + + SetTimeout(Config.Music.MetadataTimeoutMs, function() + resolve(nil) + end) + return Citizen.Await(request) +end + +local function owned_playlist(account_id, imei, playlist_id) + local condition, owner_params = owner_condition(account_id, imei) + local params = { playlist_id } + append_params(params, owner_params) + local rows = Bridge.Database.Query(([[ + SELECT `id` FROM `sky_phone_music_playlists` + WHERE `id` = ? AND %s LIMIT 1 + ]]):format(condition), params) + return rows[1] ~= nil +end + +local function valid_personal_song(account_id, imei, song_id) + local condition, owner_params = owner_condition(account_id, imei) + local params = { song_id } + append_params(params, owner_params) + local rows = Bridge.Database.Query(([[ + SELECT `id` FROM `sky_phone_music_youtube_songs` + WHERE `id` = ? AND %s LIMIT 1 + ]]):format(condition), params) + return rows[1] ~= nil +end + +Bridge.Callbacks.Register("sky_phone:music:bootstrap", function(source) + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:add-youtube", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local payload = type(data) == "table" and data or {} + local video_id = parse_youtube_id(payload.url) + if not video_id then + return { success = false, error = "invalid_youtube_url" } + end + local custom_title = optional_text(payload.title) + local custom_artist = optional_text(payload.artist) + local custom_title_length = text_length(custom_title) + local custom_artist_length = text_length(custom_artist) + if (custom_title and (not custom_title_length or custom_title_length > 160)) + or (custom_artist and (not custom_artist_length or custom_artist_length > 120)) + then + return { success = false, error = "invalid_song_metadata" } + end + + local condition, owner_params = owner_condition(account_id, imei) + local duplicate_params = { video_id } + append_params(duplicate_params, owner_params) + local duplicate = Bridge.Database.Query(([[ + SELECT `id` FROM `sky_phone_music_youtube_songs` + WHERE `video_id` = ? AND %s LIMIT 1 + ]]):format(condition), duplicate_params) + if duplicate[1] then + return { success = false, error = "song_exists" } + end + local count_rows = Bridge.Database.Query(([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_music_youtube_songs` WHERE %s + ]]):format(condition), owner_params) + if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.Music.MaximumPersonalSongs then + return { success = false, error = "song_limit" } + end + + local metadata = (not custom_title or not custom_artist) + and fetch_youtube_metadata(video_id) + or nil + local title = custom_title or metadata and metadata.title or "YouTube " .. video_id + local artist = custom_artist or metadata and metadata.artist or "YouTube" + local id = uuid() + if account_id then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_youtube_songs` + (`id`, `account_id`, `device_imei`, `video_id`, `title`, `artist`) + VALUES (?, ?, NULL, ?, ?, ?) + ]], { id, account_id, video_id, title, artist }) + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_youtube_songs` + (`id`, `account_id`, `device_imei`, `video_id`, `title`, `artist`) + VALUES (?, NULL, ?, ?, ?, ?) + ]], { id, imei, video_id, title, artist }) + end + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:remove-youtube", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local song_id = type(data) == "table" and data.id or nil + if type(song_id) ~= "string" or not valid_personal_song(account_id, imei, song_id) then + return { success = false, error = "song_not_found" } + end + + local condition, owner_params = owner_condition(account_id, imei, "p") + local item_params = { song_id } + append_params(item_params, owner_params) + Bridge.Database.Query(([[ + DELETE `i` FROM `sky_phone_music_playlist_items` AS `i` + INNER JOIN `sky_phone_music_playlists` AS `p` ON `p`.`id` = `i`.`playlist_id` + WHERE `i`.`source` = 'youtube' AND `i`.`song_id` = ? AND %s + ]]):format(condition), item_params) + + local song_condition, song_owner_params = owner_condition(account_id, imei) + local delete_params = { song_id } + append_params(delete_params, song_owner_params) + Bridge.Database.Query(([[ + DELETE FROM `sky_phone_music_youtube_songs` WHERE `id` = ? AND %s + ]]):format(song_condition), delete_params) + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:create-playlist", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local name = trim(type(data) == "table" and data.name or nil) + local name_length = text_length(name) + if not name_length or name_length < 1 or name_length > Config.Music.PlaylistNameMaxLength then + return { success = false, error = "invalid_playlist" } + end + + local condition, owner_params = owner_condition(account_id, imei) + local count_rows = Bridge.Database.Query(([[ + SELECT COUNT(*) AS `count` FROM `sky_phone_music_playlists` WHERE %s + ]]):format(condition), owner_params) + if (tonumber(count_rows[1] and count_rows[1].count) or 0) >= Config.Music.MaximumPlaylists then + return { success = false, error = "playlist_limit" } + end + + local id = uuid() + if account_id then + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `device_imei`, `name`) + VALUES (?, ?, NULL, ?) + ]], { id, account_id, name }) + else + Bridge.Database.Query([[ + INSERT INTO `sky_phone_music_playlists` (`id`, `account_id`, `device_imei`, `name`) + VALUES (?, NULL, ?, ?) + ]], { id, imei, name }) + end + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:rename-playlist", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local playlist_id = type(data) == "table" and data.id or nil + local name = trim(type(data) == "table" and data.name or nil) + local name_length = text_length(name) + if type(playlist_id) ~= "string" + or not name_length + or name_length < 1 + or name_length > Config.Music.PlaylistNameMaxLength + then + return { success = false, error = "invalid_playlist" } + end + + if not owned_playlist(account_id, imei, playlist_id) then + return { success = false, error = "playlist_not_found" } + end + local condition, owner_params = owner_condition(account_id, imei) + local params = { name, playlist_id } + append_params(params, owner_params) + Bridge.Database.Query(([[ + UPDATE `sky_phone_music_playlists` SET `name` = ? + WHERE `id` = ? AND %s + ]]):format(condition), params) + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:delete-playlist", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local playlist_id = type(data) == "table" and data.id or nil + if type(playlist_id) ~= "string" then + return { success = false, error = "invalid_playlist" } + end + local condition, owner_params = owner_condition(account_id, imei) + local params = { playlist_id } + append_params(params, owner_params) + local result = Bridge.Database.Query(([[ + DELETE FROM `sky_phone_music_playlists` WHERE `id` = ? AND %s + ]]):format(condition), params) + if affected_rows(result) ~= 1 then + return { success = false, error = "playlist_not_found" } + end + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:add-to-playlist", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local playlist_id = type(data) == "table" and data.playlistId or nil + local source_type = type(data) == "table" and data.source or nil + local song_id = type(data) == "table" and data.songId or nil + if type(playlist_id) ~= "string" + or type(song_id) ~= "string" + or not owned_playlist(account_id, imei, playlist_id) + or (source_type ~= "server" and source_type ~= "youtube") + or (source_type == "server" and not server_tracks_by_id[song_id]) + or (source_type == "youtube" and not valid_personal_song(account_id, imei, song_id)) + then + return { success = false, error = "invalid_song" } + end + + 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 + return { success = true, data = bootstrap(account_id, imei) } +end) + +Bridge.Callbacks.Register("sky_phone:music:remove-from-playlist", function(source, data) + if not SkyPhone.AllowOperation(source, "music_write", Config.Music.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local account_id, imei, error_response = session_owner(source) + if not imei then + return error_response + end + local playlist_id = type(data) == "table" and data.playlistId or nil + local source_type = type(data) == "table" and data.source or nil + local song_id = type(data) == "table" and data.songId or nil + if type(playlist_id) ~= "string" + or type(song_id) ~= "string" + or (source_type ~= "server" and source_type ~= "youtube") + or not owned_playlist(account_id, imei, playlist_id) + then + return { success = false, error = "invalid_song" } + end + Bridge.Database.Query([[ + DELETE FROM `sky_phone_music_playlist_items` + WHERE `playlist_id` = ? AND `source` = ? AND `song_id` = ? + ]], { playlist_id, source_type, song_id }) + return { success = true, data = bootstrap(account_id, imei) } +end) +end) diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 9b7bb9a..72fb684 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -470,6 +470,46 @@ local function link_account(source, account) ]], params = { account.id, session.imei }, }, + { + query = [[ + UPDATE `sky_phone_music_playlist_items` AS `item` + INNER JOIN `sky_phone_music_playlists` AS `playlist` + ON `playlist`.`id` = `item`.`playlist_id` + INNER JOIN `sky_phone_music_youtube_songs` AS `local_song` + ON `item`.`source` = 'youtube' AND `local_song`.`id` = `item`.`song_id` + INNER JOIN `sky_phone_music_youtube_songs` AS `cloud_song` + ON `cloud_song`.`account_id` = ? AND `cloud_song`.`video_id` = `local_song`.`video_id` + SET `item`.`song_id` = `cloud_song`.`id` + WHERE `playlist`.`device_imei` = ? AND `playlist`.`account_id` IS NULL + AND `local_song`.`device_imei` = ? AND `local_song`.`account_id` IS NULL + ]], + params = { account.id, session.imei, session.imei }, + }, + { + query = [[ + DELETE `local_song` FROM `sky_phone_music_youtube_songs` AS `local_song` + INNER JOIN `sky_phone_music_youtube_songs` AS `cloud_song` + ON `cloud_song`.`account_id` = ? AND `cloud_song`.`video_id` = `local_song`.`video_id` + WHERE `local_song`.`device_imei` = ? AND `local_song`.`account_id` IS NULL + ]], + params = { account.id, session.imei }, + }, + { + query = [[ + UPDATE `sky_phone_music_youtube_songs` + SET `account_id` = ?, `device_imei` = NULL + WHERE `device_imei` = ? AND `account_id` IS NULL + ]], + params = { account.id, session.imei }, + }, + { + query = [[ + UPDATE `sky_phone_music_playlists` + SET `account_id` = ?, `device_imei` = NULL + WHERE `device_imei` = ? AND `account_id` IS NULL + ]], + params = { account.id, session.imei }, + }, }) then return { success = false, error = "request_failed" } end @@ -1001,6 +1041,14 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) query = "DELETE FROM `sky_phone_media` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { session.imei }, }, + { + query = "DELETE FROM `sky_phone_music_playlists` WHERE `device_imei` = ? AND `account_id` IS NULL", + params = { session.imei }, + }, + { + query = "DELETE FROM `sky_phone_music_youtube_songs` WHERE `device_imei` = ? AND `account_id` IS NULL", + params = { session.imei }, + }, { query = "DELETE FROM `sky_phone_contacts` WHERE `device_imei` = ? AND `account_id` IS NULL", params = { session.imei }, diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index a146dc2..dc53b92 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -241,6 +241,50 @@ CREATE TABLE IF NOT EXISTS `sky_phone_calendar_events` ( FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_music_youtube_songs` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NULL, + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL, + `video_id` CHAR(11) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `title` VARCHAR(160) NOT NULL, + `artist` VARCHAR(120) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_music_youtube_account` (`account_id`, `video_id`), + UNIQUE KEY `uniq_sky_phone_music_youtube_device` (`device_imei`, `video_id`), + KEY `idx_sky_phone_music_youtube_account` (`account_id`, `created_at`), + KEY `idx_sky_phone_music_youtube_device` (`device_imei`, `created_at`), + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_music_playlists` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `account_id` BIGINT UNSIGNED NULL, + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NULL, + `name` VARCHAR(80) NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_music_playlists_account` (`account_id`, `updated_at`), + KEY `idx_sky_phone_music_playlists_device` (`device_imei`, `updated_at`), + FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_music_playlist_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `playlist_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `source` ENUM('server', 'youtube') NOT NULL, + `song_id` VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `position` SMALLINT UNSIGNED NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_music_playlist_song` (`playlist_id`, `source`, `song_id`), + KEY `idx_sky_phone_music_playlist_order` (`playlist_id`, `position`, `id`), + FOREIGN KEY (`playlist_id`) REFERENCES `sky_phone_music_playlists` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_radio_profiles` ( `identifier` VARCHAR(80) NOT NULL, `history` LONGTEXT NOT NULL,