From b5e9086b24a971a19737afef2b1cb49d5255daba Mon Sep 17 00:00:00 2001 From: Technomancer702 Date: Tue, 13 Oct 2026 20:29:08 -0700 Subject: [PATCH] Fix mobile username matching and expose VOD subtitles --- README.md | 2 + package-lock.json | 4 +- package.json | 2 +- public/index.html | 6 +- public/js/components/CaptionTracks.js | 26 +++++++ public/js/components/PlaybackProbe.js | 2 +- public/js/pages/WatchPage.js | 7 ++ public/login.html | 2 +- server/db.js | 24 +++++- test/authentication.test.js | 45 ++++++++++++ test/db-concurrency.test.js | 20 +++++ test/login-fullscreen.test.js | 6 ++ test/vod-subtitles.test.js | 101 ++++++++++++++++++++++++++ 13 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 test/vod-subtitles.test.js diff --git a/README.md b/README.md index 15955a5..c251aae 100644 --- a/README.md +++ b/README.md @@ -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 `. 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. diff --git a/package-lock.json b/package-lock.json index 7650c2f..d2839eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8c84129..8a0c462 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/index.html b/public/index.html index 617b76b..f0063a5 100644 --- a/public/index.html +++ b/public/index.html @@ -1105,9 +1105,9 @@ - + - + @@ -1119,7 +1119,7 @@ - + diff --git a/public/js/components/CaptionTracks.js b/public/js/components/CaptionTracks.js index e86f7a8..dd13751 100644 --- a/public/js/components/CaptionTracks.js +++ b/public/js/components/CaptionTracks.js @@ -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(); diff --git a/public/js/components/PlaybackProbe.js b/public/js/components/PlaybackProbe.js index 7064241..564b071 100644 --- a/public/js/components/PlaybackProbe.js +++ b/public/js/components/PlaybackProbe.js @@ -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(''); diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index 7c4f354..e7f2c71 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -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); diff --git a/public/login.html b/public/login.html index 203ac5f..0f15c61 100644 --- a/public/login.html +++ b/public/login.html @@ -183,7 +183,7 @@
-
diff --git a/server/db.js b/server/db.js index 3c1a054..68a4957 100644 --- a/server/db.js +++ b/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'); } } diff --git a/test/authentication.test.js b/test/authentication.test.js index 3a1a0f9..fdadcf5 100644 --- a/test/authentication.test.js +++ b/test/authentication.test.js @@ -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'); +}); diff --git a/test/db-concurrency.test.js b/test/db-concurrency.test.js index a2aa214..717ebdb 100644 --- a/test/db-concurrency.test.js +++ b/test/db-concurrency.test.js @@ -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); +}); diff --git a/test/login-fullscreen.test.js b/test/login-fullscreen.test.js index 4116848..44a8310 100644 --- a/test/login-fullscreen.test.js +++ b/test/login-fullscreen.test.js @@ -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'); diff --git a/test/vod-subtitles.test.js b/test/vod-subtitles.test.js new file mode 100644 index 0000000..d860cfa --- /dev/null +++ b/test/vod-subtitles.test.js @@ -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(`
${scripts.map(src => ``).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(); + });