diff --git a/frontend/app.js b/frontend/app.js index 4a2e2a2..672153d 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -2952,6 +2952,7 @@ function wirePlayerEvents() { }); el.addEventListener('playing', () => { if (!masterIs(el)) return; + if (WEB && current?.meta) window.ServerBackfill?.report(current.meta); if (performance.getEntriesByName('ytp:tap').length) { perfMeasure('ytp:tap-to-play', 'ytp:tap'); try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ } diff --git a/frontend/index.html b/frontend/index.html index 7b3375a..bf6108e 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -662,6 +662,7 @@ + diff --git a/frontend/server-backfill.js b/frontend/server-backfill.js new file mode 100644 index 0000000..2784f4a --- /dev/null +++ b/frontend/server-backfill.js @@ -0,0 +1,34 @@ +/* Best-effort discovery for playback that never requests server streams. */ +(function(root) { + 'use strict'; + function createReporter({ fetch: send = root.fetch?.bind(root), online = () => root.navigator?.onLine !== false, enabled = () => true } = {}) { + const seen = new Set(), deferred = new Map(); + function report(meta) { + if (!enabled() || !send || !meta || !/^[A-Za-z0-9_-]{11}$/.test(meta.id) || meta.custom || meta.upload || !meta.title || meta.title === meta.id || seen.has(meta.id)) return; + // Snapshot only the known metadata, never stream URLs or local blob art. + let thumbnail; + try { + const url = new URL(meta.thumbnail); + if (url.protocol === 'https:' && /^(?:i|i\d)\.ytimg\.com$/.test(url.hostname) && !url.port && !url.username && !url.password) thumbnail = url.href; + } catch { /* server supplies canonical art */ } + const card = { id: meta.id, title: String(meta.title).slice(0,300), channel: String(meta.channel || meta.artist || meta.uploader || '').slice(0,300), + duration: Math.max(0, Math.min(86400, Number(meta.duration) || 0)), ...(thumbnail ? { thumbnail } : {}) }; + if (!online()) { + deferred.set(meta.id, card); + if (deferred.size > 256) deferred.delete(deferred.keys().next().value); + return; + } + seen.add(meta.id); deferred.delete(meta.id); + // No synchronous/network failure can interrupt the player event handler. + try { + Promise.resolve(send(`/api/media/${encodeURIComponent(card.id)}/meta`, { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(card), + signal: root.AbortSignal?.timeout?.(10000), + })).catch(() => {}); + } catch { /* best effort; a later session can report again */ } + } + return { report, flush: () => { if (online()) for (const card of [...deferred.values()]) report(card); } }; + } + if (typeof module !== 'undefined') module.exports = { createReporter }; + else { const reporter = createReporter(); root.ServerBackfill = reporter; root.addEventListener('online', reporter.flush); } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/server-backfill.test.js b/frontend/server-backfill.test.js new file mode 100644 index 0000000..1a2e011 --- /dev/null +++ b/frontend/server-backfill.test.js @@ -0,0 +1,26 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createReporter } = require('./server-backfill.js'); +const meta={id:'0gfX0dFLaBc',title:'Because You are God',artist:'Cathedral of Praise Worship',duration:240,thumbnail:'blob:device-art'}; +test('first playback reports known metadata once per video per session',()=>{ + const requests=[];const reporter=createReporter({fetch:(url,options)=>{requests.push({url,options});return Promise.resolve({ok:true});}}); + reporter.report(meta);reporter.report(meta);reporter.report({...meta,id:'bbbbbbbbbbb'}); + assert.equal(requests.length,2);assert.equal(requests[0].url,'/api/media/0gfX0dFLaBc/meta'); + const card=JSON.parse(requests[0].options.body);assert.equal(card.channel,meta.artist);assert.equal(card.duration,240);assert.equal(card.thumbnail,undefined); + assert.equal(requests[0].options.method,'POST'); +}); +test('offline plays defer until online and repeated events do not multiply reports',()=>{ + let online=false;const requests=[];const reporter=createReporter({online:()=>online,fetch:(...args)=>{requests.push(args);return Promise.resolve();}}); + reporter.report(meta);reporter.report(meta);reporter.flush();assert.equal(requests.length,0); + online=true;reporter.flush();reporter.flush();reporter.report(meta);assert.equal(requests.length,1); +}); +test('uploads, edits, unknown titles and disabled native shells never report',()=>{ + const requests=[];const fetch=(...args)=>{requests.push(args);};const reporter=createReporter({fetch}); + for(const item of [{...meta,id:'upl_123'},{...meta,custom:true},{...meta,upload:true},{...meta,title:meta.id},{...meta,title:''}])reporter.report(item); + createReporter({fetch,enabled:()=>false}).report(meta);assert.equal(requests.length,0); +}); +test('synchronous and rejected network errors are isolated from playback',async()=>{ + assert.doesNotThrow(()=>createReporter({fetch:()=>{throw new Error('offline');}}).report(meta)); + assert.doesNotThrow(()=>createReporter({fetch:()=>Promise.reject(new Error('offline'))}).report(meta)); + await new Promise(resolve=>setImmediate(resolve)); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 79409c7..c1ede0f 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -88,6 +88,7 @@ const SHELL = [ '/downloads-page.js', '/offline-pages.css', '/saved-page.js', + '/server-backfill.js', '/saved-page.css', '/party-dj.js', '/lyrics-window.js', diff --git a/tests/offline-pages.spec.js b/tests/offline-pages.spec.js index 6732b0a..0ea4e04 100644 --- a/tests/offline-pages.spec.js +++ b/tests/offline-pages.spec.js @@ -61,3 +61,13 @@ test('saved deletion is confirmed and a storage failure retains the file',async( await page.locator('#listFilterInput').fill('extended'); await expect(page.locator('.saved-card')).toHaveCount(1); }); +test('actual device playback reports metadata once without requesting streams',async({page})=>{ + await openClassic(page); + const reports=[];await page.route('**/api/media/*/meta',route=>{reports.push(route.request().postDataJSON());return route.fulfill({contentType:'application/json',body:'{"ok":true,"known":false,"cache":"queued"}'});}); + await page.evaluate(()=>{ + current={meta:{id:'0gfX0dFLaBc',title:'Phone saved song',channel:'Phone artist',duration:240},qualities:[]}; + Player.master=els.audio;Player.secondary=null; + els.audio.dispatchEvent(new Event('playing'));els.audio.dispatchEvent(new Event('playing')); + }); + await expect.poll(()=>reports.length).toBe(1);expect(reports[0]).toMatchObject({id:'0gfX0dFLaBc',title:'Phone saved song',channel:'Phone artist'}); +});