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>
173 lines
7.6 KiB
TypeScript
173 lines
7.6 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|