fix(aiui): play the node's own files before reaching for the network
usePlayer.play() never looked at song.sources[] — a real library track went straight to (CSP-blocked) Wavlake and reported 'Not found on Wavlake' while its bytes sat on the operator's disk. Node sources (same-origin /content/<id>, Range-streamed) now play first; Wavlake is the metadata-only fallback. FilmDetail likewise only played YouTube sources; own/peer/IndeeHub sources (same-origin, media-src 'self') now win, YouTube stays the free-films fallback. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -131,8 +131,15 @@ const videoStore = useVideoPlayerStore()
|
|||||||
|
|
||||||
const isExternal = computed(() => props.film.id.startsWith('ext-'))
|
const isExternal = computed(() => props.film.id.startsWith('ext-'))
|
||||||
|
|
||||||
|
// Node files first: own/peer/IndeeHub sources are same-origin `/content/<id>`
|
||||||
|
// streams the node serves with Range support (media-src 'self' allows them).
|
||||||
|
// YouTube is the free-films fallback, not the default — a real library file
|
||||||
|
// must never route out to YouTube when its bytes are on the operator's node.
|
||||||
|
const NODE_SOURCE_TYPES = new Set(['nextcloud', 'plex', 'indeehub'])
|
||||||
const playableSource = computed(() =>
|
const playableSource = computed(() =>
|
||||||
props.film.sources.find(s => s.type === 'youtube' || s.url.includes('youtube.com'))
|
props.film.sources.find(
|
||||||
|
s => NODE_SOURCE_TYPES.has(s.type) && (s.url.startsWith('/') || s.url.startsWith(window.location.origin)),
|
||||||
|
) ?? props.film.sources.find(s => s.type === 'youtube' || s.url.includes('youtube.com'))
|
||||||
)
|
)
|
||||||
|
|
||||||
function openVideo() {
|
function openVideo() {
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ describe('usePlayer', () => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns expected API shape', () => {
|
it('returns expected API shape', () => { const player = usePlayer()
|
||||||
const player = usePlayer()
|
|
||||||
expect(player.currentSong).toBeDefined()
|
expect(player.currentSong).toBeDefined()
|
||||||
expect(player.isPlaying).toBeDefined()
|
expect(player.isPlaying).toBeDefined()
|
||||||
expect(player.isLoading).toBeDefined()
|
expect(player.isLoading).toBeDefined()
|
||||||
@@ -54,6 +53,23 @@ describe('usePlayer', () => {
|
|||||||
expect(player.clearQueue).toBeTypeOf('function')
|
expect(player.clearQueue).toBeTypeOf('function')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('plays a node-source song directly without touching Wavlake', async () => {
|
||||||
|
const fetchSpy = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
const player = usePlayer()
|
||||||
|
const song = {
|
||||||
|
...makeSong('n1', 'Node Track', 'Node Artist'),
|
||||||
|
sources: [{ type: 'funkwhale', name: 'This node', url: '/content/abc-123' }],
|
||||||
|
}
|
||||||
|
await player.play(song as never)
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled()
|
||||||
|
expect(player.isLoading.value).toBe(false)
|
||||||
|
expect(player.error.value).toBeNull()
|
||||||
|
expect(player.currentSong.value?.id).toBe('n1')
|
||||||
|
expect(player.playableSource.value?.url).toBe('/content/abc-123')
|
||||||
|
expect(player.playableSource.value?.source).toBe('node')
|
||||||
|
})
|
||||||
|
|
||||||
it('starts with no track', () => {
|
it('starts with no track', () => {
|
||||||
const player = usePlayer()
|
const player = usePlayer()
|
||||||
expect(player.hasTrack.value).toBe(false)
|
expect(player.hasTrack.value).toBe(false)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'plyr/dist/plyr.css'
|
|||||||
import { apiFetch } from '@/utils/api-fetch'
|
import { apiFetch } from '@/utils/api-fetch'
|
||||||
|
|
||||||
interface MusicSearchResult {
|
interface MusicSearchResult {
|
||||||
source: 'wavlake'
|
source: 'wavlake' | 'node'
|
||||||
type: 'stream'
|
type: 'stream'
|
||||||
url: string
|
url: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -250,6 +250,32 @@ export function usePlayer() {
|
|||||||
currentTime.value = 0
|
currentTime.value = 0
|
||||||
duration.value = 0
|
duration.value = 0
|
||||||
|
|
||||||
|
// A real node track carries its own sources (same-origin `/content/<id>`,
|
||||||
|
// Range-streamed by the node itself) — play those FIRST. Wavlake is the
|
||||||
|
// metadata-only fallback; on a node it is CSP-blocked outright, so every
|
||||||
|
// library track used to end at "Not found on Wavlake" without ever
|
||||||
|
// trying the bytes sitting on the operator's own disk.
|
||||||
|
const nodeSource = song.sources?.find(
|
||||||
|
(s) => !!s.url && (s.url.startsWith('/') || s.url.startsWith(window.location.origin)),
|
||||||
|
)
|
||||||
|
if (nodeSource) {
|
||||||
|
isLoading.value = false
|
||||||
|
error.value = null
|
||||||
|
playableSource.value = {
|
||||||
|
source: 'node',
|
||||||
|
type: 'stream',
|
||||||
|
url: nodeSource.url,
|
||||||
|
title: song.title,
|
||||||
|
artist: song.artist,
|
||||||
|
coverUrl: song.coverUrl,
|
||||||
|
duration: song.duration,
|
||||||
|
}
|
||||||
|
if (containerEl) {
|
||||||
|
initPlayer(playableSource.value)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const query = `${song.title} ${song.artist}`.trim()
|
const query = `${song.title} ${song.artist}`.trim()
|
||||||
const result = await searchMusic(query, song.title, song.artist)
|
const result = await searchMusic(query, song.title, song.artist)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user