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'});
+});