MERGE - integrate Music app with Housing

Preserve the Housing app registry while integrating the Music app and its frontend, server, configuration, database, and locale changes from origin/dev.
This commit is contained in:
Type
2026-08-09 22:35:34 +02:00
25 changed files with 4330 additions and 22 deletions
+41
View File
@@ -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
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Music">
<defs>
<linearGradient id="music-gradient" x1="16" y1="8" x2="112" y2="120" gradientUnits="userSpaceOnUse">
<stop stop-color="#ff5c78"/>
<stop offset=".52" stop-color="#fa2d63"/>
<stop offset="1" stop-color="#d5117b"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="url(#music-gradient)"/>
<path fill="#fff" d="M91.5 24.5v57.3c0 11.8-8.8 20.7-20.4 20.7-9.3 0-16.7-5.7-16.7-13.6 0-8.2 7.8-14.5 17.9-14.5 4.1 0 7.4.9 9.8 2.2V45.4L50 52.1v38.1c0 11.8-8.8 20.7-20.4 20.7-9.3 0-16.7-5.7-16.7-13.6 0-8.2 7.8-14.5 17.9-14.5 4 0 7.4.9 9.8 2.2V36.8c0-3.2 2.2-5.9 5.3-6.5l38.2-8c3.8-.8 7.4 2.1 7.4 6.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 754 B

+22 -6
View File
@@ -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<HTMLElement | null>(null)
const brightness = ref(phone.preferences.settings.screenBrightness)
@@ -315,27 +318,40 @@ onBeforeUnmount(() => {
>
<div class="control-center__media-copy">
<span>{{ phone.t('ControlCenter.media') }}</span>
<strong>{{ phone.t('ControlCenter.notPlaying') }}</strong>
<strong>{{
music.currentTrack?.title ?? phone.t('ControlCenter.notPlaying')
}}</strong>
<small v-if="music.currentTrack">{{
music.currentTrack.artist
}}</small>
</div>
<div class="control-center__media-controls">
<button
type="button"
disabled
:disabled="!music.currentTrack"
:aria-label="phone.t('ControlCenter.previous')"
@click="music.previous"
>
<SkipBack aria-hidden="true" />
</button>
<button
type="button"
disabled
:aria-label="phone.t('ControlCenter.play')"
:disabled="!music.currentTrack && !music.allTracks.length"
:aria-label="
phone.t(
music.isPlaying ? 'Common.pause' : 'ControlCenter.play',
)
"
@click="music.toggle"
>
<Play aria-hidden="true" />
<Pause v-if="music.isPlaying" aria-hidden="true" />
<Play v-else aria-hidden="true" />
</button>
<button
type="button"
disabled
:disabled="!music.currentTrack"
:aria-label="phone.t('ControlCenter.next')"
@click="music.next"
>
<SkipForward aria-hidden="true" />
</button>
@@ -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',
+7
View File
@@ -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([
+16
View File
@@ -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(
+12 -13
View File
@@ -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()
},
}
}
+253
View File
@@ -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<void> {
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<typeof fetch>()
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<Response>((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)
})
})
+570
View File
@@ -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<string, number | string>
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<YouTubeApi> | null = null
let youtubePlayer: YouTubePlayer | null = null
let youtubeProgressTimer: number | null = null
export function musicTrackKey(
track: Pick<MusicTrack, 'id' | 'source'>,
): 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<boolean> {
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<YouTubeApi> {
if (window.YT?.Player) return Promise.resolve(window.YT)
if (youtubeApiPromise) return youtubeApiPromise
youtubeApiPromise = new Promise<YouTubeApi>((resolve, reject) => {
let settled = false
const previousReady = window.onYouTubeIframeAPIReady
let script = document.querySelector<HTMLScriptElement>(
'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<void> {
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<void>((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<string, unknown>,
): Promise<boolean> {
this.isLoading = true
const response = await nuiCall<MusicBootstrap>(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<boolean> {
void loadYouTubeApi().catch((error) => {
console.warn('[Music] YouTube API preload failed:', error)
})
return this.request('music:bootstrap', {})
},
async addYouTube(url: string, title = '', artist = ''): Promise<boolean> {
return this.request('music:add-youtube', { artist, title, url })
},
async removeYouTube(id: string): Promise<boolean> {
return this.request('music:remove-youtube', { id })
},
async createPlaylist(name: string): Promise<boolean> {
return this.request('music:create-playlist', { name })
},
async renamePlaylist(id: string, name: string): Promise<boolean> {
return this.request('music:rename-playlist', { id, name })
},
async deletePlaylist(id: string): Promise<boolean> {
return this.request('music:delete-playlist', { id })
},
async addToPlaylist(
playlistId: string,
track: MusicTrack,
): Promise<boolean> {
return this.request('music:add-to-playlist', {
playlistId,
songId: track.id,
source: track.source,
})
},
async removeFromPlaylist(
playlistId: string,
track: MusicTrack,
): Promise<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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
},
},
})
+89
View File
@@ -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',
+1
View File
@@ -16,6 +16,7 @@ export type PhoneAppId =
| 'map'
| 'notes'
| 'radio'
| 'music'
| 'photos'
| 'app-store'
| 'settings'
+30
View File
@@ -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[]
}
+1
View File
@@ -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 },
}
File diff suppressed because it is too large Load Diff
+208 -1
View File
@@ -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
+3 -2
View File
@@ -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',
},
})
+36
View File
@@ -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",
+19
View File
@@ -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,
}
+28
View File
@@ -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 <Id>.ogg or <Id>.mp3. Artwork is optional and must be named
<Id>.webp, <Id>.png, <Id>.jpg or <Id>.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.
+3
View File
@@ -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'
+8
View File
@@ -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",
+69
View File
@@ -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 = {
+787
View File
@@ -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)
+48
View File
@@ -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 },
+44
View File
@@ -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,