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:
archipelago
2026-08-07 10:31:57 -04:00
co-authored by Claude
parent 7d57e2c39d
commit c2e71bc7e5
3 changed files with 53 additions and 4 deletions
@@ -131,8 +131,15 @@ const videoStore = useVideoPlayerStore()
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(() =>
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() {
@@ -38,8 +38,7 @@ describe('usePlayer', () => {
}))
})
it('returns expected API shape', () => {
const player = usePlayer()
it('returns expected API shape', () => { const player = usePlayer()
expect(player.currentSong).toBeDefined()
expect(player.isPlaying).toBeDefined()
expect(player.isLoading).toBeDefined()
@@ -54,6 +53,23 @@ describe('usePlayer', () => {
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', () => {
const player = usePlayer()
expect(player.hasTrack.value).toBe(false)
+27 -1
View File
@@ -5,7 +5,7 @@ import 'plyr/dist/plyr.css'
import { apiFetch } from '@/utils/api-fetch'
interface MusicSearchResult {
source: 'wavlake'
source: 'wavlake' | 'node'
type: 'stream'
url: string
title?: string
@@ -250,6 +250,32 @@ export function usePlayer() {
currentTime.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 result = await searchMusic(query, song.title, song.artist)