mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
feat: improve music playback and asset discovery
This commit is contained in:
@@ -26,7 +26,7 @@ The standalone `Music` app plays audio only inside the current player's NUI. It
|
||||
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 their stable ID, title, artist, and paths in
|
||||
`sky_phone/config/music`. Define only their stable ID, title, and artist in
|
||||
`sky_phone/config/music.lua`:
|
||||
|
||||
```lua
|
||||
@@ -35,15 +35,25 @@ Tracks = {
|
||||
Id = "night-drive",
|
||||
Title = "Night Drive",
|
||||
Artist = "Sky Records",
|
||||
File = "config/music/night-drive.ogg",
|
||||
Artwork = "config/music/night-drive.webp",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest'
|
||||
|
||||
import type { MusicTrack } from '@/types/music'
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
+112
-15
@@ -49,9 +49,12 @@ declare global {
|
||||
}
|
||||
|
||||
const audio = new Audio()
|
||||
audio.preload = 'metadata'
|
||||
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
|
||||
@@ -62,6 +65,88 @@ export function musicTrackKey(
|
||||
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)
|
||||
@@ -82,15 +167,14 @@ function startYoutubeProgress(): void {
|
||||
function bindAudioEvents(): void {
|
||||
if (audioBound) return
|
||||
audioBound = true
|
||||
audio.addEventListener('durationchange', () => {
|
||||
const store = useMusicStore()
|
||||
if (store.currentTrack?.source === 'server') {
|
||||
store.duration = Number.isFinite(audio.duration) ? audio.duration : 0
|
||||
}
|
||||
})
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -260,9 +344,8 @@ async function loadYouTubeTrack(videoId: string): Promise<void> {
|
||||
}
|
||||
|
||||
function stopActiveMedia(): void {
|
||||
audio.pause()
|
||||
audio.removeAttribute('src')
|
||||
audio.load()
|
||||
playbackGeneration += 1
|
||||
resetAudioSource()
|
||||
youtubePlayer?.pauseVideo()
|
||||
stopYoutubeProgress()
|
||||
}
|
||||
@@ -379,6 +462,7 @@ export const useMusicStore = defineStore('music', {
|
||||
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(
|
||||
@@ -393,7 +477,8 @@ export const useMusicStore = defineStore('music', {
|
||||
this.playbackError = ''
|
||||
try {
|
||||
if (track.source === 'server' && track.url) {
|
||||
audio.src = new URL(track.url, window.location.href).href
|
||||
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) {
|
||||
@@ -402,6 +487,8 @@ export const useMusicStore = defineStore('music', {
|
||||
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'
|
||||
@@ -449,10 +536,20 @@ export const useMusicStore = defineStore('music', {
|
||||
await this.play(this.queue[index]!, this.queue)
|
||||
},
|
||||
seek(seconds: number): void {
|
||||
const next = Math.max(0, Math.min(this.duration || seconds, seconds))
|
||||
this.currentTime = next
|
||||
if (this.currentTrack?.source === 'server') audio.currentTime = next
|
||||
else youtubePlayer?.seekTo(next, true)
|
||||
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))
|
||||
|
||||
@@ -1430,6 +1430,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.music-searchbar {
|
||||
width: calc(100% - 24px);
|
||||
margin: 0 12px 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
Config.Music = {
|
||||
-- Put server-owned audio and artwork directly in config/music.
|
||||
-- Paths must stay inside that directory. Restart the resource after changes.
|
||||
-- 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",
|
||||
-- File = "config/music/night-drive.ogg",
|
||||
-- Artwork = "config/music/night-drive.webp",
|
||||
-- },
|
||||
},
|
||||
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
Place server-owned .mp3 and .ogg files in this directory.
|
||||
|
||||
Optional artwork can be stored here as .png, .jpg, .jpeg or .webp. Register
|
||||
each track in ../music.lua using paths such as:
|
||||
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:
|
||||
|
||||
File = "config/music/night-drive.ogg"
|
||||
Artwork = "config/music/night-drive.webp"
|
||||
Tracks = {
|
||||
{ Id = "night-drive", Title = "Night Drive", Artist = "Sky Records" },
|
||||
}
|
||||
|
||||
Use file names containing only letters, numbers, dots, underscores or hyphens.
|
||||
Subdirectories are supported. Restart the sky_phone resource after changing
|
||||
tracks or files; a frontend build is not required.
|
||||
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.
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
Bridge.Database.AfterMigration("sky_phone", function()
|
||||
local server_tracks = {}
|
||||
local server_tracks_by_id = {}
|
||||
local music_asset_prefix = "config/music/"
|
||||
local audio_extensions = { mp3 = true, ogg = true }
|
||||
local artwork_extensions = { jpeg = true, jpg = true, png = true, webp = true }
|
||||
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
|
||||
@@ -27,27 +38,149 @@ local function optional_text(value)
|
||||
return normalized ~= "" and normalized or nil
|
||||
end
|
||||
|
||||
local function normalize_music_asset_path(value, allowed_extensions)
|
||||
local path = trim(value)
|
||||
if not path
|
||||
or path:sub(1, #music_asset_prefix) ~= music_asset_prefix
|
||||
or not path:match("^[%w%._%-%/]+$")
|
||||
or path:find("..", 1, true)
|
||||
or path:find("//", 1, true)
|
||||
or path:find("\\", 1, true)
|
||||
then
|
||||
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 extension = path:match("%.([%w]+)$")
|
||||
if not extension or not allowed_extensions[extension:lower()] then
|
||||
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 path
|
||||
|
||||
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)
|
||||
return ("https://cfx-nui-%s/%s"):format(GetCurrentResourceName(), 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)
|
||||
@@ -67,20 +200,20 @@ local function uuid()
|
||||
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 = trim(configured.Id)
|
||||
local title = trim(configured.Title)
|
||||
local artist = trim(configured.Artist)
|
||||
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)
|
||||
local file = normalize_music_asset_path(configured.File, audio_extensions)
|
||||
local configured_artwork = optional_text(configured.Artwork)
|
||||
local artwork = configured_artwork
|
||||
and normalize_music_asset_path(configured_artwork, artwork_extensions)
|
||||
or nil
|
||||
|
||||
if not id
|
||||
or not id:match("^[%w%-_]+$")
|
||||
or not id:match("^[A-Za-z0-9_-]+$")
|
||||
or #id > 48
|
||||
or not title_length
|
||||
or title_length < 1
|
||||
@@ -88,27 +221,66 @@ local function normalize_server_tracks()
|
||||
or not artist_length
|
||||
or artist_length < 1
|
||||
or artist_length > 120
|
||||
or not file
|
||||
or (configured_artwork and not artwork)
|
||||
or server_tracks_by_id[id]
|
||||
then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Ignored invalid or duplicate Config.Music.Tracks entry at index %s.",
|
||||
"[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
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user