Fix mobile username matching and expose VOD subtitles

This commit is contained in:
Technomancer702
2026-10-13 20:29:08 -07:00
parent 735b6299c9
commit b5e9086b24
13 changed files with 235 additions and 12 deletions

View File

@ -352,6 +352,8 @@ nodecast-tv/
## Authentication and reverse proxies ## 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. 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. 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
View File

@ -1,12 +1,12 @@
{ {
"name": "nodecast-tv", "name": "nodecast-tv",
"version": "2.2.5", "version": "2.2.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodecast-tv", "name": "nodecast-tv",
"version": "2.2.5", "version": "2.2.6",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@ffprobe-installer/ffprobe": "^2.1.2", "@ffprobe-installer/ffprobe": "^2.1.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "nodecast-tv", "name": "nodecast-tv",
"version": "2.2.5", "version": "2.2.6",
"description": "Modern IPTV Player", "description": "Modern IPTV Player",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"main": "server/index.js", "main": "server/index.js",

View File

@ -1105,9 +1105,9 @@
<script src="/js/api.js?v=auth-2"></script> <script src="/js/api.js?v=auth-2"></script>
<script src="/js/components/PlaybackSessions.js?v=1"></script> <script src="/js/components/PlaybackSessions.js?v=1"></script>
<script src="/js/components/VodDuration.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/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/PlayerFullscreen.js?v=1"></script>
<script src="/js/components/VideoPlayer.js?v=8"></script> <script src="/js/components/VideoPlayer.js?v=8"></script>
<script src="/js/components/ChannelList.js?v=4"></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/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script> <script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></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> <script src="/js/app.js?v=5"></script>
</body> </body>

View File

@ -6,6 +6,8 @@ class CaptionTracks {
this.hls = null; this.hls = null;
this.probe = null; this.probe = null;
this.remoteTracks = []; this.remoteTracks = [];
this.timeOffset = 0;
this.remoteCueTimes = new WeakMap();
this.hlsTracks = new Map(); this.hlsTracks = new Map();
this.retired = new Set(); this.retired = new Set();
this.generation = 0; this.generation = 0;
@ -48,6 +50,7 @@ class CaptionTracks {
this.generation++; this.generation++;
this.hls = null; this.hls = null;
this.probe = null; this.probe = null;
this.timeOffset = 0;
this.retireNativeTracks(); this.retireNativeTracks();
this.onChange(); this.onChange();
} }
@ -56,6 +59,24 @@ class CaptionTracks {
this.probe = { subtitles: info?.subtitles || [], url }; 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() { prepareMediaRecovery() {
const tracks = Array.from(this.hlsTracks, ([key, entry]) => ({ key, entry, const tracks = Array.from(this.hlsTracks, ([key, entry]) => ({ key, entry,
cues: Array.from(entry.element.track.cues || []), mode: entry.element.track.mode })); 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.label = subtitle.title || subtitle.language || 'Subtitles';
element.srclang = subtitle.language || ''; element.srclang = subtitle.language || '';
element.src = `/api/subtitle?url=${encodeURIComponent(this.probe.url)}&index=${subtitle.index}`; 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.remoteTracks.push(element);
this.video.appendChild(element); this.video.appendChild(element);
} }
@ -251,6 +275,8 @@ class CaptionTracks {
if (this.generation !== generation) return; if (this.generation !== generation) return;
this.off(); this.off();
track.mode = 'showing'; track.mode = 'showing';
const remote = this.remoteTracks.find(element => element.track === track);
if (remote) this.alignRemoteCues(remote);
} }); } });
} }
const counts = new Map(); const counts = new Map();

View File

@ -1,7 +1,7 @@
/** Metadata probes belong to the active playback, and share an ordered lease across replacements. */ /** Metadata probes belong to the active playback, and share an ordered lease across replacements. */
class PlaybackProbe { class PlaybackProbe {
constructor(fetchImpl = fetch) { constructor(fetchImpl = fetch) {
this.fetch = fetchImpl; this.fetch = (url, options) => fetchImpl(url, options);
this.sequence = 0; this.sequence = 0;
this.leaseId = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), this.leaseId = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)),
byte => byte.toString(16).padStart(2, '0')).join(''); byte => byte.toString(16).padStart(2, '0')).join('');

View File

@ -448,6 +448,7 @@ class WatchPage {
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' }); const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
probeInfo = info; probeInfo = info;
this.getCaptionTracks().setProbe(info, url);
this.setSourceDuration(info.duration); this.setSourceDuration(info.duration);
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`); 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 // TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)'); console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)'); this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
this.getCaptionTracks().useNative();
const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio], program); const finalUrl = this.getRemuxUrl(url, info.audioCodecs || [info.audio], program);
this.video.src = finalUrl; this.video.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
@ -531,6 +533,7 @@ class WatchPage {
const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' }); const info = await (this.playbackProbe ||= new PlaybackProbe(fetch)).read(url, { program, userAgent: ua || '' });
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
videoCodec = info.video; videoCodec = info.video;
this.getCaptionTracks().setProbe(info, url);
} catch (e) { if (e.name === 'AbortError') return; console.warn('Probe failed for force audio, assuming h264'); } } catch (e) { if (e.name === 'AbortError') return; console.warn('Probe failed for force audio, assuming h264'); }
if (this.playbackGeneration !== playbackGeneration) return; if (this.playbackGeneration !== playbackGeneration) return;
@ -560,6 +563,8 @@ class WatchPage {
} }
} }
if (this.playbackGeneration !== playbackGeneration) return; 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); const finalUrl = this.getRemuxUrl(url, probeInfo?.audioCodecs || (probeInfo?.audio ? [probeInfo.audio] : []), program);
this.video.src = finalUrl; this.video.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
@ -583,6 +588,7 @@ class WatchPage {
} else { } else {
// Direct playback for mp4/mkv/avi // Direct playback for mp4/mkv/avi
this.updateTranscodeStatus('direct', 'Direct Play'); this.updateTranscodeStatus('direct', 'Direct Play');
this.getCaptionTracks().useNative({ allowProbe: !looksLikeHls });
this.video.src = finalUrl; this.video.src = finalUrl;
this.video.play().catch(e => { this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', 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.audioRecovery = new HlsAudioRecovery(hls, this.video, Hls.Events,
() => this.hls === hls && this.playbackGeneration === generation, () => this.hls === hls && this.playbackGeneration === generation,
{ beforeRecovery: () => this.getCaptionTracks().prepareMediaRecovery() }); { beforeRecovery: () => this.getCaptionTracks().prepareMediaRecovery() });
this.getCaptionTracks().setTimeOffset(this.currentSessionId ? this.playbackTimeOffset : 0);
this.getCaptionTracks().bindHls(hls, Hls.Events); this.getCaptionTracks().bindHls(hls, Hls.Events);
this.hls.loadSource(url); this.hls.loadSource(url);
this.hls.attachMedia(this.video); this.hls.attachMedia(this.video);

View File

@ -183,7 +183,7 @@
<form id="login-form" method="post" action="/api/auth/login"> <form id="login-form" method="post" action="/api/auth/login">
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <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"> placeholder="Enter your username">
</div> </div>

View File

@ -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 // User operations
const users = { const users = {
async getAll() { async getAll() {
@ -415,7 +423,13 @@ const users = {
async getByUsername(username) { async getByUsername(username) {
const db = await loadDb(); 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) { async getByOidcId(oidcId) {
@ -436,7 +450,8 @@ const users = {
// Check if username already exists // Check if username already exists
if (initialSetup && db.users.length) throw new Error('Setup already completed'); 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'); throw new Error('Username already exists');
} }
@ -468,9 +483,10 @@ const users = {
throw new Error('User not found'); 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 (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'); throw new Error('Username already exists');
} }
} }

View File

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

View File

@ -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.url, 'https://example.invalid/current');
assert.equal(res.value.name, 'Renamed'); 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);
});

View File

@ -101,6 +101,9 @@ test('login prevents native GET credential leaks after missing or failed scripts
page.on('pageerror', () => {}); page.on('pageerror', () => {});
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort()); await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.goto(base + '/login.html'); 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'); await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
assert.equal(await page.locator('#submit-btn').isDisabled(), true); assert.equal(await page.locator('#submit-btn').isDisabled(), true);
await page.locator('#password').press('Enter'); 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.goto(base + '/login.html');
await page.waitForFunction(() => !document.querySelector('#submit-btn').disabled); 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('#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'); await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
const sent = page.waitForRequest(request => request.url() === base + endpoint && request.method() === 'POST'); const sent = page.waitForRequest(request => request.url() === base + endpoint && request.method() === 'POST');
await page.click('#submit-btn'); await page.click('#submit-btn');

101
test/vod-subtitles.test.js Normal file
View File

@ -0,0 +1,101 @@
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');
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 === '../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 = [];
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);
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();
});