Report phone playback metadata once per browser session
This commit is contained in:
@@ -2952,6 +2952,7 @@ function wirePlayerEvents() {
|
|||||||
});
|
});
|
||||||
el.addEventListener('playing', () => {
|
el.addEventListener('playing', () => {
|
||||||
if (!masterIs(el)) return;
|
if (!masterIs(el)) return;
|
||||||
|
if (WEB && current?.meta) window.ServerBackfill?.report(current.meta);
|
||||||
if (performance.getEntriesByName('ytp:tap').length) {
|
if (performance.getEntriesByName('ytp:tap').length) {
|
||||||
perfMeasure('ytp:tap-to-play', 'ytp:tap');
|
perfMeasure('ytp:tap-to-play', 'ytp:tap');
|
||||||
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
|
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
|
||||||
|
|||||||
@@ -662,6 +662,7 @@
|
|||||||
<script src="downloads.js"></script>
|
<script src="downloads.js"></script>
|
||||||
<script src="downloads-page.js"></script>
|
<script src="downloads-page.js"></script>
|
||||||
<script src="saved-page.js"></script>
|
<script src="saved-page.js"></script>
|
||||||
|
<script src="server-backfill.js"></script>
|
||||||
<script src="party-dj.js"></script>
|
<script src="party-dj.js"></script>
|
||||||
<script src="lyrics-window.js"></script>
|
<script src="lyrics-window.js"></script>
|
||||||
<script src="midi.js"></script>
|
<script src="midi.js"></script>
|
||||||
|
|||||||
34
frontend/server-backfill.js
Normal file
34
frontend/server-backfill.js
Normal file
@@ -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);
|
||||||
26
frontend/server-backfill.test.js
Normal file
26
frontend/server-backfill.test.js
Normal file
@@ -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));
|
||||||
|
});
|
||||||
@@ -88,6 +88,7 @@ const SHELL = [
|
|||||||
'/downloads-page.js',
|
'/downloads-page.js',
|
||||||
'/offline-pages.css',
|
'/offline-pages.css',
|
||||||
'/saved-page.js',
|
'/saved-page.js',
|
||||||
|
'/server-backfill.js',
|
||||||
'/saved-page.css',
|
'/saved-page.css',
|
||||||
'/party-dj.js',
|
'/party-dj.js',
|
||||||
'/lyrics-window.js',
|
'/lyrics-window.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 page.locator('#listFilterInput').fill('extended');
|
||||||
await expect(page.locator('.saved-card')).toHaveCount(1);
|
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'});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user