Support X and Twitter video URLs with reliable resolved playback
Share yt-dlp resolution across YouTube and X/Twitter, preserve separate audio/video inputs and headers, and use finite duration metadata directly. Prefer direct HTTPS formats for X, support multi-video posts and explicit selections, and enforce single-connection hosts before resolution and on resolved sources. Include the related recorded-media probing, seeking recovery, and metadata polling fixes, with regression coverage for playback and protected sources.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import { test } from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = await fs.readFile(new URL('../public/app.js', import.meta.url), 'utf8');
|
||||
|
||||
function loadPlayer(metadataFetch) {
|
||||
const nodes = new Map();
|
||||
const createElement = () => ({
|
||||
hidden: true, disabled: true, value: '', currentTime: 0,
|
||||
addEventListener() {}, replaceChildren() {}, setAttribute() {},
|
||||
getContext() { return {}; }, style: { setProperty() {} },
|
||||
});
|
||||
const context = vm.createContext({
|
||||
document: {
|
||||
querySelector(selector) {
|
||||
if (!nodes.has(selector)) nodes.set(selector, createElement());
|
||||
return nodes.get(selector);
|
||||
},
|
||||
createElement, addEventListener() {},
|
||||
},
|
||||
window: {
|
||||
addEventListener() {},
|
||||
// Advance polling without waiting in real time.
|
||||
setTimeout(callback) { queueMicrotask(callback); },
|
||||
},
|
||||
fetch: async url => url.startsWith('/api/session/') ? metadataFetch(url) : ({ ok: true, json: async () => ({}) }),
|
||||
console,
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
vm.runInContext("state.session = { id: 'recorded', metadataStatus: 'pending' };", context);
|
||||
return { nodes, run: code => vm.runInContext(code, context) };
|
||||
}
|
||||
|
||||
test('seeking becomes enabled after a slow probe and transient metadata request errors', async () => {
|
||||
let polls = 0;
|
||||
const player = loadPlayer(async () => {
|
||||
polls += 1;
|
||||
assert.ok(polls <= 25, 'polling must stop once duration is available');
|
||||
if (polls === 1) throw new Error('Temporary network failure');
|
||||
if (polls === 2) return { ok: false, status: 503 };
|
||||
const ready = polls === 25;
|
||||
return { ok: true, json: async () => ({
|
||||
id: 'recorded', metadataStatus: ready ? 'ready' : 'pending',
|
||||
duration: ready ? 7783 : null, seekable: ready,
|
||||
}) };
|
||||
});
|
||||
await player.run("refreshSessionMetadata('recorded')");
|
||||
assert.equal(polls, 25, 'must continue beyond the old twenty-poll limit');
|
||||
assert.equal(player.nodes.get('#seek').disabled, false);
|
||||
assert.equal(player.nodes.get('#seek').max, '7783');
|
||||
assert.equal(player.nodes.get('#total-time').textContent, '2:09:43');
|
||||
});
|
||||
|
||||
test('metadata responses from an old stream cannot overwrite a restarted player', async () => {
|
||||
let resolveMetadata;
|
||||
const player = loadPlayer(() => new Promise(resolve => { resolveMetadata = resolve; }));
|
||||
const pending = player.run("refreshSessionMetadata('recorded')");
|
||||
player.run("state.streamGeneration += 1; state.duration = 60; state.seekable = true; syncPlayhead();");
|
||||
resolveMetadata({ ok: true, json: async () => ({ metadataStatus: 'unavailable', duration: null, seekable: false }) });
|
||||
await pending;
|
||||
assert.equal(player.nodes.get('#seek').disabled, false);
|
||||
assert.equal(player.nodes.get('#seek').max, '60');
|
||||
});
|
||||
|
||||
test('metadata polling stops when probing finishes without a duration', async () => {
|
||||
let polls = 0;
|
||||
const player = loadPlayer(async () => {
|
||||
polls += 1;
|
||||
assert.equal(polls, 1);
|
||||
return { ok: true, json: async () => ({ metadataStatus: 'unavailable', duration: null, seekable: false }) };
|
||||
});
|
||||
await player.run("refreshSessionMetadata('recorded')");
|
||||
assert.equal(player.nodes.get('#seek').disabled, true);
|
||||
assert.equal(polls, 1);
|
||||
});
|
||||
|
||||
test('a delayed ready message cannot disable seeking after duration is discovered', () => {
|
||||
const player = loadPlayer(() => { throw new Error('Unexpected metadata request'); });
|
||||
player.run('updateSessionMetadata({ duration: 7783, seekable: true });');
|
||||
player.run('handleControlMessage(JSON.stringify({ type: "ready", duration: null, seekable: false }));');
|
||||
assert.equal(player.nodes.get('#seek').disabled, false);
|
||||
assert.equal(player.nodes.get('#seek').max, '7783');
|
||||
});
|
||||
Reference in New Issue
Block a user