fix(video): play the actual video, and answer Range requests
Every video in the app was unplayable. Two independent defects, either one
sufficient on its own, and nothing in the suite covered either — no test
anywhere played media or asserted a `<video>` src.
1. The lightbox handed `<video>` a JPEG.
`pickMediaUrl` is mime-agnostic, and compression only ever produces a THUMBNAIL
for a video (one `ffmpeg -vframes 1` frame) — no preview, no display. So in the
DEFAULT saver mode the element's src resolved to `/api/v1/upload/{id}/thumbnail`,
served as `image/jpeg` with `nosniff` so the browser can't even sniff its way
out. Chromium reports DEMUXER_ERROR_COULD_NOT_OPEN.
Fixed in the lightbox rather than in `pickMediaUrl`: FeedListCard shares that
helper and legitimately wants the thumbnail for its `<img>` poster, so a central
mime branch would break the feed. This mirrors the rule the diashow already
applies ("videos play the original file directly"). Added `preload="none"` so
saver-mode guests on cellular still fetch nothing until they press play — there
is no smaller video derivative to offer them — plus `playsinline`, without which
iOS hijacks playback into fullscreen.
2. `stream_media_file` ignored Range entirely.
It took no request headers, so it could not see `Range`; it always returned 200
with the whole body and never sent Accept-Ranges or Content-Range. iOS Safari
opens every `<video>` with a `Range: bytes=0-1` probe and abandons the load
without a 206 — so video failed on the app's primary platform even in `original`
mode, where the src was already correct.
Adds single-range support (`bytes=N-`, `bytes=N-M`, `bytes=-S`) with 206 +
Content-Range, 416 + `bytes */len` past EOF, and Accept-Ranges advertised on
every response. Anything it won't handle — multi-range, non-bytes units, garbage
— falls back to a full 200, which RFC 9110 explicitly permits and which is safer
than guessing. All four media routes share the helper, so seeking works
uniformly.
`get_original` now serves `inline` instead of `attachment`. An attachment
disposition is hostile to a `<video>` element, and this route is the only source
of playable video bytes; it also matches what the UI promises, since the action
is labelled "Original anzeigen" — view, not download. `no-store` is deliberately
kept so a takedown still revokes access promptly; ranges work fine under it, the
client just re-fetches.
Tests: 11 unit tests pin the parser (the iOS `bytes=0-1` probe, inclusive ends,
suffix ranges, clamping past EOF, 416 vs 200, malformed fallbacks). A new
03-feed/video-playback spec asserts the src is the original and not the
thumbnail, that the browser accepts the bytes as media (readyState > 0, no
MediaError), that no video bytes are delivered before play, and that Range
returns the correct 206 slices and a 416 past EOF — verified on both Chromium
and WebKit.
The "not downloaded before play" test asserts no *delivered body* rather than no
request: WebKit opens a connection for a preload="none" video and immediately
aborts it (GET, no Range, status 0, nothing transferred) while Chromium issues
nothing at all. The portable guarantee is that no response carrying bytes
completes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
172
e2e/specs/03-feed/video-playback.spec.ts
Normal file
172
e2e/specs/03-feed/video-playback.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Regression guard — videos must actually play.
|
||||
*
|
||||
* Two independent defects made every video unplayable, and nothing in the suite covered
|
||||
* either one (no test anywhere played media or asserted a `<video>` src):
|
||||
*
|
||||
* 1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
|
||||
* Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
|
||||
* the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
|
||||
* served as image/jpeg with nosniff so the browser can't even sniff its way out.
|
||||
* Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
|
||||
*
|
||||
* 2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
|
||||
* an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
|
||||
* `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
|
||||
* app's primary platform even in `original` data mode.
|
||||
*
|
||||
* Fixing either alone still leaves video broken, so both are asserted here.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { BASE } from '../../helpers/env';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');
|
||||
|
||||
async function seedVideo(jwt: string): Promise<string> {
|
||||
const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
|
||||
filename: 'clip.mp4',
|
||||
contentType: 'video/mp4',
|
||||
caption: 'ein Video',
|
||||
});
|
||||
if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
|
||||
return ((await res.json()) as { id: string }).id;
|
||||
}
|
||||
|
||||
test.describe('Video — the lightbox plays it', () => {
|
||||
test('the <video> src is the original, not the thumbnail JPEG', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
const g = await guest('VideoWatcher');
|
||||
const id = await seedVideo(g.jwt);
|
||||
|
||||
await signIn(page, g);
|
||||
await page.goto('/feed');
|
||||
|
||||
const card = page.locator('article').first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||
|
||||
const video = page.locator('video');
|
||||
await expect(video).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The bug in one assertion: this was `/thumbnail` in the default data mode.
|
||||
await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);
|
||||
|
||||
// The poster SHOULD still be the thumbnail — that's what it's for.
|
||||
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
|
||||
|
||||
// And the browser must accept the bytes as media. preload="none" means nothing is
|
||||
// fetched until we ask, so drive a load explicitly and wait for metadata.
|
||||
const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
|
||||
el.preload = 'metadata';
|
||||
el.load();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (el.readyState > 0) return resolve();
|
||||
el.addEventListener('loadedmetadata', () => resolve(), { once: true });
|
||||
el.addEventListener('error', () => resolve(), { once: true });
|
||||
setTimeout(resolve, 15_000);
|
||||
});
|
||||
return { ready: el.readyState, err: el.error?.message ?? null };
|
||||
});
|
||||
expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
|
||||
expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('the video body is not downloaded before the user presses play', async ({
|
||||
page,
|
||||
guest,
|
||||
signIn,
|
||||
}) => {
|
||||
// saver mode exists to protect a guest's mobile data, and there is no smaller video
|
||||
// derivative to fall back to — so opening the lightbox must not pull the file down.
|
||||
//
|
||||
// Asserting "no request at all" would be wrong: WebKit opens a connection for a
|
||||
// preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
|
||||
// response status 0, nothing transferred), whereas Chromium issues nothing. The
|
||||
// portable guarantee — and the one that actually protects the data plan — is that no
|
||||
// response carrying the body ever completes. Dropping preload="none" fails this.
|
||||
const g = await guest('VideoThrifty');
|
||||
const id = await seedVideo(g.jwt);
|
||||
|
||||
await signIn(page, g);
|
||||
const delivered: string[] = [];
|
||||
page.on('response', (r) => {
|
||||
if (!r.url().includes(`/upload/${id}/original`)) return;
|
||||
// status 0 = aborted before any bytes landed.
|
||||
if (r.status() === 200 || r.status() === 206) {
|
||||
delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/feed');
|
||||
const card = page.locator('article').first();
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
|
||||
await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Media — HTTP Range', () => {
|
||||
test('a range request is answered 206 with the right slice', async ({ guest }) => {
|
||||
const g = await guest('RangeReader');
|
||||
const id = await seedVideo(g.jwt);
|
||||
const url = `${BASE}/api/v1/upload/${id}/original`;
|
||||
|
||||
const full = await fetch(url);
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
|
||||
const total = Number(full.headers.get('content-length'));
|
||||
expect(total).toBeGreaterThan(0);
|
||||
|
||||
// The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
|
||||
const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
|
||||
expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
|
||||
expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
|
||||
expect(Number(probe.headers.get('content-length'))).toBe(2);
|
||||
expect((await probe.arrayBuffer()).byteLength).toBe(2);
|
||||
|
||||
// A mid-file seek must return the matching slice, not the whole body.
|
||||
const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
|
||||
expect(mid.status).toBe(206);
|
||||
expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
|
||||
const midBytes = Buffer.from(await mid.arrayBuffer());
|
||||
expect(midBytes.byteLength).toBe(10);
|
||||
expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));
|
||||
|
||||
// Open-ended range runs to EOF.
|
||||
const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
|
||||
expect(tail.status).toBe(206);
|
||||
expect(Number(tail.headers.get('content-length'))).toBe(5);
|
||||
|
||||
// Past EOF must be 416 — answering 200 makes a player re-request forever.
|
||||
const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
|
||||
expect(bad.status).toBe(416);
|
||||
expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
|
||||
});
|
||||
|
||||
test('image derivatives are range-capable too', async ({ guest, db }) => {
|
||||
// Same helper serves all four media routes, so the guarantee is uniform.
|
||||
const g = await guest('RangeImages');
|
||||
const res = await uploadRaw(
|
||||
g.jwt,
|
||||
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
|
||||
{ filename: 'r.jpg', contentType: 'image/jpeg' }
|
||||
);
|
||||
const { id } = (await res.json()) as { id: string };
|
||||
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
|
||||
|
||||
const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
});
|
||||
expect(preview.status).toBe(206);
|
||||
expect(Number(preview.headers.get('content-length'))).toBe(10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user