Merge pull request #169 from technomancer702/codex/login-vod-subtitles
Fix mobile login and VOD subtitles/remux playback (2.2.6)
This commit is contained in:
@ -352,6 +352,8 @@ nodecast-tv/
|
||||
|
||||
## Authentication and reverse proxies
|
||||
|
||||
Usernames are matched without regard to case, and their display spelling is preserved. If an older database has accounts differing only by case, those accounts still require their exact spelling until an administrator renames them; ambiguous mixed-case logins are rejected. New accounts and renames cannot create case-only duplicates.
|
||||
|
||||
Browser playback and API requests use an HttpOnly authentication cookie. API clients can also send the token returned by `/api/auth/login` as `Authorization: Bearer <token>`. Existing accounts and settings are retained, but clients must sign in again after upgrading from the older token format. Logout revokes that login; changing a password invalidates all of that account's existing tokens.
|
||||
|
||||
Random authentication secrets are created once in `data/security.json` and survive restarts. Keep that file with the persistent data volume. `JWT_SECRET` and `SESSION_SECRET` can override the generated secrets for managed deployments.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@ffprobe-installer/ffprobe": "^2.1.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nodecast-tv",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.6",
|
||||
"description": "Modern IPTV Player",
|
||||
"license": "GPL-3.0-only",
|
||||
"main": "server/index.js",
|
||||
|
||||
@ -1105,9 +1105,9 @@
|
||||
<script src="/js/api.js?v=auth-2"></script>
|
||||
<script src="/js/components/PlaybackSessions.js?v=1"></script>
|
||||
<script src="/js/components/VodDuration.js?v=1"></script>
|
||||
<script src="/js/components/CaptionTracks.js?v=2"></script>
|
||||
<script src="/js/components/CaptionTracks.js?v=3"></script>
|
||||
<script src="/js/components/HlsAudioRecovery.js?v=1"></script>
|
||||
<script src="/js/components/PlaybackProbe.js?v=1"></script>
|
||||
<script src="/js/components/PlaybackProbe.js?v=2"></script>
|
||||
<script src="/js/components/PlayerFullscreen.js?v=1"></script>
|
||||
<script src="/js/components/VideoPlayer.js?v=8"></script>
|
||||
<script src="/js/components/ChannelList.js?v=4"></script>
|
||||
@ -1119,7 +1119,7 @@
|
||||
<script src="/js/pages/MoviesPage.js?v=4"></script>
|
||||
<script src="/js/pages/SeriesPage.js?v=1"></script>
|
||||
<script src="/js/pages/Settings.js?v=2"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=8"></script>
|
||||
<script src="/js/pages/WatchPage.js?v=9"></script>
|
||||
<script src="/js/app.js?v=5"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@ -6,6 +6,8 @@ class CaptionTracks {
|
||||
this.hls = null;
|
||||
this.probe = null;
|
||||
this.remoteTracks = [];
|
||||
this.timeOffset = 0;
|
||||
this.remoteCueTimes = new WeakMap();
|
||||
this.hlsTracks = new Map();
|
||||
this.retired = new Set();
|
||||
this.generation = 0;
|
||||
@ -48,6 +50,7 @@ class CaptionTracks {
|
||||
this.generation++;
|
||||
this.hls = null;
|
||||
this.probe = null;
|
||||
this.timeOffset = 0;
|
||||
this.retireNativeTracks();
|
||||
this.onChange();
|
||||
}
|
||||
@ -56,6 +59,24 @@ class CaptionTracks {
|
||||
this.probe = { subtitles: info?.subtitles || [], url };
|
||||
}
|
||||
|
||||
setTimeOffset(offset) {
|
||||
this.timeOffset = Number.isFinite(Number(offset)) ? Math.max(0, Number(offset)) : 0;
|
||||
for (const element of this.remoteTracks) this.alignRemoteCues(element);
|
||||
}
|
||||
|
||||
alignRemoteCues(element) {
|
||||
const track = element.track, mode = track.mode;
|
||||
if (!track.cues?.length) return;
|
||||
track.mode = 'hidden';
|
||||
for (const cue of Array.from(track.cues || [])) {
|
||||
if (!this.remoteCueTimes.has(cue)) this.remoteCueTimes.set(cue, { start: cue.startTime, end: cue.endTime });
|
||||
const original = this.remoteCueTimes.get(cue);
|
||||
cue.startTime = Math.max(0, original.start - this.timeOffset);
|
||||
cue.endTime = Math.max(0, original.end - this.timeOffset);
|
||||
}
|
||||
track.mode = mode;
|
||||
}
|
||||
|
||||
prepareMediaRecovery() {
|
||||
const tracks = Array.from(this.hlsTracks, ([key, entry]) => ({ key, entry,
|
||||
cues: Array.from(entry.element.track.cues || []), mode: entry.element.track.mode }));
|
||||
@ -97,6 +118,9 @@ class CaptionTracks {
|
||||
element.label = subtitle.title || subtitle.language || 'Subtitles';
|
||||
element.srclang = subtitle.language || '';
|
||||
element.src = `/api/subtitle?url=${encodeURIComponent(this.probe.url)}&index=${subtitle.index}`;
|
||||
element.addEventListener('load', () => {
|
||||
if (this.remoteTracks.includes(element)) this.alignRemoteCues(element);
|
||||
});
|
||||
this.remoteTracks.push(element);
|
||||
this.video.appendChild(element);
|
||||
}
|
||||
@ -251,6 +275,8 @@ class CaptionTracks {
|
||||
if (this.generation !== generation) return;
|
||||
this.off();
|
||||
track.mode = 'showing';
|
||||
const remote = this.remoteTracks.find(element => element.track === track);
|
||||
if (remote) this.alignRemoteCues(remote);
|
||||
} });
|
||||
}
|
||||
const counts = new Map();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/** Metadata probes belong to the active playback, and share an ordered lease across replacements. */
|
||||
class PlaybackProbe {
|
||||
constructor(fetchImpl = fetch) {
|
||||
this.fetch = fetchImpl;
|
||||
this.fetch = (url, options) => fetchImpl(url, options);
|
||||
this.sequence = 0;
|
||||
this.leaseId = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)),
|
||||
byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
@ -448,6 +448,7 @@ class WatchPage {
|
||||
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
probeInfo = info;
|
||||
this.getCaptionTracks().setProbe(info, url);
|
||||
this.setSourceDuration(info.duration);
|
||||
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
|
||||
|
||||
@ -484,6 +485,7 @@ class WatchPage {
|
||||
// TODO: Move remux to session logic if seeking is needed for TS files
|
||||
console.log('[WatchPage] Auto: Using remux (.ts container)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||
this.getCaptionTracks().useNative();
|
||||
const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio], program);
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
@ -531,6 +533,7 @@ class WatchPage {
|
||||
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
videoCodec = info.video;
|
||||
this.getCaptionTracks().setProbe(info, url);
|
||||
} catch (e) { if (e.name === 'AbortError') return; console.warn('Probe failed for force audio, assuming h264'); }
|
||||
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
@ -560,6 +563,8 @@ class WatchPage {
|
||||
}
|
||||
}
|
||||
if (this.playbackGeneration !== playbackGeneration) return;
|
||||
this.getCaptionTracks().setProbe(probeInfo, url);
|
||||
this.getCaptionTracks().useNative();
|
||||
const finalUrl = this.getRemuxUrl(url, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []), program);
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
@ -583,6 +588,7 @@ class WatchPage {
|
||||
} else {
|
||||
// Direct playback for mp4/mkv/avi
|
||||
this.updateTranscodeStatus('direct', 'Direct Play');
|
||||
this.getCaptionTracks().useNative({ allowProbe: !looksLikeHls });
|
||||
this.video.src = finalUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
|
||||
@ -613,6 +619,7 @@ class WatchPage {
|
||||
this.audioRecovery = new HlsAudioRecovery(hls, this.video, Hls.Events,
|
||||
() => this.hls === hls && this.playbackGeneration === generation,
|
||||
{ beforeRecovery: () => this.getCaptionTracks().prepareMediaRecovery() });
|
||||
this.getCaptionTracks().setTimeOffset(this.currentSessionId ? this.playbackTimeOffset : 0);
|
||||
this.getCaptionTracks().bindHls(hls, Hls.Events);
|
||||
this.hls.loadSource(url);
|
||||
this.hls.attachMedia(this.video);
|
||||
|
||||
@ -183,7 +183,7 @@
|
||||
<form id="login-form" method="post" action="/api/auth/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autocomplete="username"
|
||||
<input type="text" id="username" name="username" required autocomplete="username" autocapitalize="none" autocorrect="off" spellcheck="false"
|
||||
placeholder="Enter your username">
|
||||
</div>
|
||||
|
||||
|
||||
24
server/db.js
24
server/db.js
@ -401,6 +401,14 @@ const settings = {
|
||||
}
|
||||
};
|
||||
|
||||
// Preserve display spelling and IDs while preventing new case-only account collisions.
|
||||
function usernameKey(username) { return typeof username === 'string' ? username.toLowerCase() : null; }
|
||||
function validateUsername(username) {
|
||||
if (typeof username !== 'string' || !username.trim() || username.length > 64 || /[\x00-\x1f\x7f]/.test(username)) {
|
||||
throw new Error('Username must be a non-empty string of at most 64 characters');
|
||||
}
|
||||
}
|
||||
|
||||
// User operations
|
||||
const users = {
|
||||
async getAll() {
|
||||
@ -415,7 +423,13 @@ const users = {
|
||||
|
||||
async getByUsername(username) {
|
||||
const db = await loadDb();
|
||||
return db.users?.find(u => u.username === username);
|
||||
const key = usernameKey(username);
|
||||
if (key === null) return undefined;
|
||||
const matches = (db.users || []).filter(user => usernameKey(user.username) === key);
|
||||
if (matches.length === 1) return matches[0];
|
||||
// Legacy case-only duplicates keep their exact-spelling login. Never guess between accounts.
|
||||
const exact = matches.filter(user => user.username === username);
|
||||
return exact.length === 1 ? exact[0] : undefined;
|
||||
},
|
||||
|
||||
async getByOidcId(oidcId) {
|
||||
@ -436,7 +450,8 @@ const users = {
|
||||
|
||||
// Check if username already exists
|
||||
if (initialSetup && db.users.length) throw new Error('Setup already completed');
|
||||
if (db.users.some(u => u.username === userData.username)) {
|
||||
validateUsername(userData.username);
|
||||
if (db.users.some(u => usernameKey(u.username) === usernameKey(userData.username))) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
|
||||
@ -468,9 +483,10 @@ const users = {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Check if username is being changed and if it already exists
|
||||
// Existing case-only duplicates can still change passwords/roles without a rename.
|
||||
if (Object.hasOwn(updates, 'username')) validateUsername(updates.username);
|
||||
if (updates.username && updates.username !== db.users[userIndex].username) {
|
||||
if (db.users.some(u => u.username === updates.username)) {
|
||||
if (db.users.some((u, index) => index !== userIndex && usernameKey(u.username) === usernameKey(updates.username))) {
|
||||
throw new Error('Username already exists');
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,8 +128,7 @@ router.get('/', async (req, res) => {
|
||||
'-sn', '-dn',
|
||||
// Copy streams without re-encoding
|
||||
'-c', 'copy',
|
||||
// Ensure extradata is correctly extracted/converted (fixes Annex B -> AVCC issues in Firefox)
|
||||
'-bsf:v', 'dump_extra',
|
||||
// The MP4 muxer handles AVC framing; prepending container extradata corrupts AVCC packets.
|
||||
// Convert ADTS only on probed AAC tracks; leave MP3/AC3/EAC3 untouched.
|
||||
...audioFilterArgs(audioCodecs),
|
||||
// Handle timestamp discontinuities at output
|
||||
|
||||
@ -172,3 +172,48 @@ test('real authenticated media requests deliver remux and owned HLS output witho
|
||||
assert.equal((await fixture.request('/api/transcode/lease', { ...post({ playbackLeaseId: lease }), method: 'DELETE', headers })).status, 200);
|
||||
assert.equal((await fetch(`${fixture.base}${session.body.playlistUrl}`, { headers })).status, 404);
|
||||
});
|
||||
|
||||
test('local login matches case variants and preserves account IDs, roles, passwords and display spelling', async t => {
|
||||
const { request } = await serverFixture(t);
|
||||
const admin = await request('/api/auth/setup', post({ username: 'Owner', password: 'owner-password' }));
|
||||
assert.equal(admin.status, 201);
|
||||
const headers = { Cookie: admin.cookie.split(';')[0] };
|
||||
const viewer = await request('/api/auth/users', { ...post({ username: 'Viewer', password: 'viewer-password', role: 'viewer' }), headers });
|
||||
assert.equal(viewer.status, 201);
|
||||
for (const username of ['viewer', 'VIEWER', 'vIeWeR']) {
|
||||
const login = await request('/api/auth/login', post({ username, password: 'viewer-password' }));
|
||||
assert.equal(login.status, 200);
|
||||
assert.deepEqual(login.body.user, { id: viewer.body.id, username: 'Viewer', role: 'viewer' });
|
||||
}
|
||||
assert.equal((await request('/api/auth/login', post({ username: 'viewer', password: 'VIEWER-PASSWORD' }))).status, 401);
|
||||
assert.equal((await request('/api/auth/login', post({ username: 'owner', password: 'viewer-password' }))).status, 401);
|
||||
assert.equal((await request('/api/auth/users', { ...post({ username: 'VIEWER', password: 'another-password', role: 'admin' }), headers })).status, 500);
|
||||
assert.equal((await request(`/api/auth/users/${viewer.body.id}`, { method: 'PUT', body: JSON.stringify({ username: 'OWNER' }), headers })).status, 500);
|
||||
assert.equal((await request(`/api/auth/users/${viewer.body.id}`, { method: 'PUT', body: JSON.stringify({ username: 'VIEWER' }), headers })).status, 200);
|
||||
const renamed = await request('/api/auth/login', post({ username: 'viewer', password: 'viewer-password' }));
|
||||
assert.equal(renamed.body.user.id, viewer.body.id); assert.equal(renamed.body.user.username, 'VIEWER');
|
||||
});
|
||||
|
||||
test('legacy case-only accounts retain exact logins and reject ambiguous case variants until renamed', async t => {
|
||||
const fixture = await serverFixture(t);
|
||||
await fixture.request('/api/auth/setup', post({ username: 'Admin', password: 'admin-password' }));
|
||||
await fixture.stop();
|
||||
const filename = path.join(fixture.dir, 'db.json'), data = JSON.parse(fs.readFileSync(filename, 'utf8'));
|
||||
const bcrypt = require('bcryptjs');
|
||||
data.users.push({ id: data.nextId++, username: 'Twin', role: 'admin', passwordHash: await bcrypt.hash('first-password', 4) },
|
||||
{ id: data.nextId++, username: 'twin', role: 'viewer', passwordHash: await bcrypt.hash('second-password', 4) });
|
||||
fs.writeFileSync(filename, JSON.stringify(data)); await fixture.start();
|
||||
for (const [username, password, role] of [['Twin', 'first-password', 'admin'], ['twin', 'second-password', 'viewer']]) {
|
||||
const login = await fixture.request('/api/auth/login', post({ username, password }));
|
||||
assert.equal(login.status, 200); assert.equal(login.body.user.role, role);
|
||||
}
|
||||
assert.equal((await fixture.request('/api/auth/login', post({ username: 'TWIN', password: 'first-password' }))).status, 401);
|
||||
assert.equal((await fixture.request('/api/auth/login', post({ username: 'Twin', password: 'second-password' }))).status, 401);
|
||||
const owner = await fixture.request('/api/auth/login', post({ username: 'admin', password: 'admin-password' }));
|
||||
const headers = { Cookie: owner.cookie.split(';')[0] }, lower = data.users.find(user => user.username === 'twin');
|
||||
assert.equal((await fixture.request(`/api/auth/users/${lower.id}`, { method: 'PUT', body: JSON.stringify({ username: 'twin', password: 'changed-password' }), headers })).status, 200);
|
||||
assert.equal((await fixture.request(`/api/auth/users/${lower.id}`, { method: 'PUT', body: JSON.stringify({ username: 'TWIN' }), headers })).status, 500);
|
||||
assert.equal((await fixture.request(`/api/auth/users/${lower.id}`, { method: 'PUT', body: JSON.stringify({ username: 'Other' }), headers })).status, 200);
|
||||
const resolved = await fixture.request('/api/auth/login', post({ username: 'TWIN', password: 'first-password' }));
|
||||
assert.equal(resolved.status, 200); assert.equal(resolved.body.user.username, 'Twin');
|
||||
});
|
||||
|
||||
@ -106,3 +106,23 @@ test('source route does not reapply fields from a stale read', async t => {
|
||||
assert.equal(res.value.url, 'https://example.invalid/current');
|
||||
assert.equal(res.value.name, 'Renamed');
|
||||
});
|
||||
|
||||
test('case-folded username creation and renames are atomic without merging legacy accounts', async t => {
|
||||
const { db, dir } = await fixture(t);
|
||||
const created = await Promise.allSettled(['Alice', 'ALICE', 'alice'].map(username => db.users.create({ username })));
|
||||
assert.equal(created.filter(result => result.status === 'fulfilled').length, 1);
|
||||
const alice = (await db.users.getAll())[0]; assert.equal((await db.users.getByUsername('aLiCe')).id, alice.id);
|
||||
for (const username of [null, [], {}, '', ' ', 'a'.repeat(65), 'bad\nname']) await assert.rejects(db.users.create({ username }));
|
||||
const bob = await db.users.create({ username: 'Bob' });
|
||||
await assert.rejects(db.users.update(bob.id, { username: 'ALICE' }), /already exists/);
|
||||
await db.users.update(alice.id, { username: 'ALICE' }); assert.equal((await db.users.getByUsername('alice')).id, alice.id);
|
||||
const renames = await Promise.allSettled([db.users.update(alice.id, { username: 'Shared' }), db.users.update(bob.id, { username: 'SHARED' })]);
|
||||
assert.equal(renames.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal((await db.users.getAll()).length, 2);
|
||||
const legacy = { users: [{ id: 1, username: 'Old', role: 'admin' }, { id: 2, username: 'old', role: 'viewer' }], nextId: 3 };
|
||||
await fs.writeFile(path.join(dir, 'db.json'), JSON.stringify(legacy));
|
||||
assert.equal((await db.users.getByUsername('Old')).id, 1); assert.equal((await db.users.getByUsername('old')).id, 2);
|
||||
assert.equal(await db.users.getByUsername('OLD'), undefined);
|
||||
await db.users.update(2, { username: 'old', passwordHash: 'changed' });
|
||||
const saved = await db.loadDb(); assert.equal(saved.users.length, 2); assert.equal(saved.users[1].authVersion, 1);
|
||||
});
|
||||
|
||||
@ -101,6 +101,9 @@ test('login prevents native GET credential leaks after missing or failed scripts
|
||||
page.on('pageerror', () => {});
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.goto(base + '/login.html');
|
||||
assert.equal(await page.locator('#username').getAttribute('autocapitalize'), 'none');
|
||||
assert.equal(await page.locator('#username').getAttribute('autocorrect'), 'off');
|
||||
assert.equal(await page.locator('#username').getAttribute('spellcheck'), 'false');
|
||||
await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
|
||||
assert.equal(await page.locator('#submit-btn').isDisabled(), true);
|
||||
await page.locator('#password').press('Enter');
|
||||
@ -128,6 +131,9 @@ test('login prevents native GET credential leaks after missing or failed scripts
|
||||
await page.goto(base + '/login.html');
|
||||
await page.waitForFunction(() => !document.querySelector('#submit-btn').disabled);
|
||||
assert.equal(await page.locator('#submit-btn').textContent(), endpoint.endsWith('setup') ? 'Create Account' : 'Sign In');
|
||||
assert.equal(await page.locator('#username').getAttribute('autocapitalize'), 'none');
|
||||
assert.equal(await page.locator('#username').getAttribute('autocorrect'), 'off');
|
||||
assert.equal(await page.locator('#username').getAttribute('spellcheck'), 'false');
|
||||
await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
|
||||
const sent = page.waitForRequest(request => request.url() === base + endpoint && request.method() === 'POST');
|
||||
await page.click('#submit-btn');
|
||||
|
||||
114
test/vod-subtitles.test.js
Normal file
114
test/vod-subtitles.test.js
Normal file
@ -0,0 +1,114 @@
|
||||
process.env.ALLOW_LOCAL_MEDIA_URLS = 'true';
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), http = require('node:http'), vm = require('node:vm');
|
||||
const { spawnSync } = require('node:child_process'), { createRequire } = require('node:module');
|
||||
const express = require('express');
|
||||
const processDiagnostics = [];
|
||||
function route(file) {
|
||||
const filename = path.join(__dirname, '../server/routes', file), realRequire = createRequire(filename), module = { exports: {} };
|
||||
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, URL, setTimeout, clearTimeout, console: { log() {}, error() {} },
|
||||
require: id => id === 'child_process' ? { spawn: (...args) => {
|
||||
const child = realRequire(id).spawn(...args); let stderr = '';
|
||||
child.stderr.on('data', data => { stderr += data; });
|
||||
child.on('close', code => { if (code && stderr) processDiagnostics.push(stderr); });
|
||||
return child;
|
||||
} } : id === '../db' ? { settings: { get: async () => ({}) }, getUserAgent: () => 'Fixture' } : realRequire(id) }, { filename });
|
||||
return module.exports;
|
||||
}
|
||||
|
||||
test('Watch VOD probes expose real embedded subtitles for HLS and remux, align seeks, and reset on content changes',
|
||||
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
|
||||
const { chromium } = require('playwright');
|
||||
const binary = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffmpeg' : require('ffmpeg-static');
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-vod-subs-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
for (const language of ['English', 'French']) fs.writeFileSync(path.join(dir, language + '.srt'),
|
||||
`1\n00:00:01,000 --> 00:00:04,000\n${language} early\n\n2\n00:00:08,000 --> 00:00:11,000\n${language} late\n`);
|
||||
const run = args => { const result = spawnSync(binary, ['-hide_banner', '-loglevel', 'error', '-y', ...args], { encoding: 'utf8', timeout: 15000 }); assert.equal(result.status, 0, result.stderr); };
|
||||
const movie = path.join(dir, 'movie.mkv');
|
||||
run(['-f', 'lavfi', '-i', 'color=c=black:s=160x90:r=10', '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
|
||||
'-i', path.join(dir, 'English.srt'), '-i', path.join(dir, 'French.srt'), '-map', '0:v', '-map', '1:a', '-map', '2:s', '-map', '3:s',
|
||||
'-t', '12', '-c:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-g', '10', '-c:a', 'aac', '-c:s', 'srt',
|
||||
'-metadata:s:s:0', 'language=eng', '-metadata:s:s:0', 'title=English', '-metadata:s:s:1', 'language=fra', '-metadata:s:s:1', 'title=French', movie]);
|
||||
const direct = path.join(dir, 'direct.mp4');
|
||||
run(['-i', movie, '-map', '0', '-c:v', 'copy', '-c:a', 'copy', '-c:s', 'mov_text', direct]);
|
||||
for (const [name, offset] of [['video', '0'], ['seek', '6']]) run(['-ss', offset, '-i', movie, '-map', '0:v', '-map', '0:a', '-c', 'copy',
|
||||
'-f', 'hls', '-hls_time', '1', '-hls_list_size', '0', '-hls_segment_filename', path.join(dir, name + '%d.ts'), path.join(dir, name + '.m3u8')]);
|
||||
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/PlaybackProbe.js',
|
||||
'/js/components/VodDuration.js', '/js/pages/WatchPage.js'], root = path.join(__dirname, '../public');
|
||||
const app = express(), requests = [];
|
||||
app.locals.ffmpegPath = binary;
|
||||
app.locals.ffprobePath = spawnSync('ffprobe', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffprobe' : require('@ffprobe-installer/ffprobe').path;
|
||||
app.use((req, res, next) => { requests.push(req.url); next(); });
|
||||
app.get('/', (req, res) => res.type('html').send(`<video muted></video><div id="list"></div><div id="menu"></div><div id="ui"></div><script src="/hls.js"></script>${scripts.map(src => `<script src="${src}"></script>`).join('')}`));
|
||||
app.get('/hls.js', (req, res) => res.type('js').send(fs.readFileSync(require.resolve('hls.js/dist/hls.min.js'))));
|
||||
for (const file of scripts) app.get(file, (req, res) => res.type('js').send(fs.readFileSync(path.join(root, file))));
|
||||
for (const name of ['/movie.mkv', '/movie.ts']) app.get(name, (req, res) => res.type('video/x-matroska').send(fs.readFileSync(movie)));
|
||||
app.get('/direct.mp4', (req, res) => res.sendFile(direct));
|
||||
app.use('/api/probe', route('probe.js')); app.use('/api/subtitle', route('subtitle.js')); app.use('/api/remux', route('remux.js'));
|
||||
app.use(express.static(dir));
|
||||
const server = http.createServer(app); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
|
||||
t.after(() => browser.close());
|
||||
const base = `http://127.0.0.1:${server.address().port}`, page = await browser.newPage(), errors = [];
|
||||
const browserMessages = [];
|
||||
page.on('console', message => browserMessages.push(message.text()));
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
if (process.env.NODECAST_BROWSER_DEBUG) page.on('console', message => console.log('VOD browser:', message.text()));
|
||||
await page.goto(base);
|
||||
await page.evaluate(() => {
|
||||
window.API = { settings: { get: async () => window.settings } };
|
||||
window.settings = { autoTranscode: true };
|
||||
const ui = document.querySelector('#ui'), watch = window.watch = Object.create(WatchPage.prototype);
|
||||
Object.assign(watch, { video: document.querySelector('video'), captionsList: document.querySelector('#list'), captionsMenu: document.querySelector('#menu'),
|
||||
overlay: ui, nowPlaying: ui, content: { type: 'movie' }, resumeTime: 0, stopHistoryTracking() {}, saveProgress() {}, hideNowPlaying() {},
|
||||
showLoading() {}, updateTranscodeStatus() {}, updateQualityBadge() {}, setVolumeFromStorage() {},
|
||||
getHlsConfig: () => ({ enableWorker: false }),
|
||||
stopTranscodeSession() { this.currentSessionId = null; this.playbackTimeOffset = 0; },
|
||||
async startTranscodeSession(url, options) {
|
||||
this.currentSessionId = 'fixture'; this.currentTranscodeOptions = options; this.playbackTimeOffset = options.seekOffset || 0;
|
||||
return this.playbackTimeOffset ? '/seek.m3u8' : '/video.m3u8';
|
||||
} });
|
||||
});
|
||||
await page.evaluate(base => window.watch.loadVideo(base + '/movie.mkv'), base);
|
||||
await page.waitForFunction(() => document.querySelectorAll('#list button').length === 3, null, { timeout: 10000 }).catch(async error => {
|
||||
console.error('VOD state:', await page.evaluate(() => ({ src: window.watch.video.src, probe: window.watch.getCaptionTracks().probe, hls: !!window.watch.hls, list: document.querySelector('#list').textContent })), errors, requests); throw error;
|
||||
});
|
||||
assert.deepEqual(await page.locator('#list button').allTextContents(), ['Off', 'English', 'French']);
|
||||
assert.equal(requests.some(url => url.startsWith('/api/subtitle')), false, 'Unselected subtitles must not open provider connections');
|
||||
await page.locator('#list button').nth(2).click();
|
||||
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && track.cues?.length === 2));
|
||||
await page.evaluate(() => { window.watch.video.pause(); window.watch.video.currentTime = 2; });
|
||||
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French early')));
|
||||
await page.evaluate(() => window.watch.seekToContentTime(6));
|
||||
await page.waitForFunction(() => window.watch.hls?.url?.endsWith('/seek.m3u8') && document.querySelectorAll('#list button').length === 3);
|
||||
await page.locator('#list button').nth(2).click();
|
||||
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.cues || []).some(cue => cue.text === 'French late' && cue.startTime > 1.9 && cue.startTime < 2.1)));
|
||||
await page.evaluate(() => { window.watch.video.pause(); window.watch.video.currentTime = 3; });
|
||||
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French late')));
|
||||
// Reversing an offset restores original timings, including cues clipped by a forward seek.
|
||||
await page.evaluate(() => { window.watch.getCaptionTracks().setTimeOffset(0); window.watch.video.currentTime = 2; });
|
||||
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French early')));
|
||||
for (const settings of [{ autoTranscode: false, forceTranscode: true }, { autoTranscode: false, forceRemux: true }, { autoTranscode: true }, { autoTranscode: true, direct: true }]) {
|
||||
await page.evaluate(async ({ base, settings }) => {
|
||||
window.settings = settings;
|
||||
const original = window.watch.playbackProbe.read.bind(window.watch.playbackProbe);
|
||||
if (settings.autoTranscode && !settings.direct) window.watch.playbackProbe.read = async (...args) => ({ ...await original(...args), needsTranscode: false, needsRemux: true });
|
||||
await window.watch.loadVideo(base + (settings.direct ? '/direct.mp4' : '/movie.ts')); window.watch.playbackProbe.read = original;
|
||||
}, { base, settings });
|
||||
await page.waitForFunction(() => document.querySelectorAll('#list button').length === 3 && window.watch.video.readyState >= 2, null, { timeout: 10000 }).catch(async error => {
|
||||
console.error('Playback mode failure:', settings, await page.evaluate(() => ({ ready: window.watch.video.readyState, src: window.watch.video.src,
|
||||
mediaError: window.watch.video.error?.message, probe: window.watch.getCaptionTracks().probe,
|
||||
list: document.querySelector('#list').textContent, hls: !!window.watch.hls })), browserMessages, processDiagnostics, requests);
|
||||
throw error;
|
||||
});
|
||||
assert.equal(await page.locator('video > track').count(), 2);
|
||||
await page.locator('#list button').nth(2).click();
|
||||
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.cues || []).some(cue => cue.text === 'French early' && cue.startTime > 0.9 && cue.startTime < 1.1)), null, { timeout: 10000 }).catch(async error => { console.error('Caption mode:', settings, await page.evaluate(() => ({ offset: window.watch.getCaptionTracks().timeOffset, probe: window.watch.getCaptionTracks().probe, tracks: Array.from(window.watch.video.textTracks, track => ({ label: track.label, mode: track.mode, cues: Array.from(track.cues || [], cue => ({ text: cue.text, start: cue.startTime, end: cue.endTime })) })) }))); throw error; });
|
||||
}
|
||||
await page.evaluate(async base => { window.settings = {}; await window.watch.loadVideo(base + '/video.m3u8'); }, base);
|
||||
await page.waitForFunction(() => window.watch.getCaptionTracks().manifestReady && document.querySelectorAll('#list button').length === 1);
|
||||
assert.equal(await page.locator('video > track').count(), 0); assert.deepEqual(errors, []);
|
||||
await page.evaluate(() => window.watch.stop()); await page.close();
|
||||
});
|
||||
Reference in New Issue
Block a user