From c2e71bc7e51ac5d4f356165502437ea34a7ac077 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 7 Aug 2026 10:31:57 -0400 Subject: [PATCH] fix(aiui): play the node's own files before reaching for the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/, 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 --- .../app/src/components/content/FilmDetail.vue | 9 +++++- .../composables/__tests__/usePlayer.test.ts | 20 +++++++++++-- .../packages/app/src/composables/usePlayer.ts | 28 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/aiui/packages/app/src/components/content/FilmDetail.vue b/aiui/packages/app/src/components/content/FilmDetail.vue index f9f20171..1036d93f 100644 --- a/aiui/packages/app/src/components/content/FilmDetail.vue +++ b/aiui/packages/app/src/components/content/FilmDetail.vue @@ -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/` +// 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() { diff --git a/aiui/packages/app/src/composables/__tests__/usePlayer.test.ts b/aiui/packages/app/src/composables/__tests__/usePlayer.test.ts index eb56c2da..64aa4a78 100644 --- a/aiui/packages/app/src/composables/__tests__/usePlayer.test.ts +++ b/aiui/packages/app/src/composables/__tests__/usePlayer.test.ts @@ -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) diff --git a/aiui/packages/app/src/composables/usePlayer.ts b/aiui/packages/app/src/composables/usePlayer.ts index 90554562..3907242f 100644 --- a/aiui/packages/app/src/composables/usePlayer.ts +++ b/aiui/packages/app/src/composables/usePlayer.ts @@ -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/`, + // 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)