feat: support audio & vidéo — lecteurs HTML5 intégrés #109
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m22s
CI / test (push) Successful in 4m32s
CI / build (push) Successful in 2m6s
CI / e2e (push) Successful in 12m57s

This commit is contained in:
2026-09-23 09:14:34 -04:00
parent eccbf7474e
commit e2417cb5ab
26 changed files with 1057 additions and 121 deletions
+10
View File
@@ -106,6 +106,16 @@ def test_vault_dir(tmp_path: Path) -> str:
encoding="utf-8",
)
# Audio/video media (roadmap #109) — indexed as metadata-only binaries,
# streamed via /api/media. Prefer the committed E2E fixture when present.
repo_media = Path(__file__).resolve().parent.parent / "test_vault" / "sample-audio.mp3"
if repo_media.exists():
(vault / "sample-audio.mp3").write_bytes(repo_media.read_bytes())
else:
(vault / "sample-audio.mp3").write_bytes(
b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
)
# File with accents in title
(vault / "café_crème.md").write_text(
"---\ntitle: Café Crème\n---\n# Café Crème\nUn bon café.\n",
+75
View File
@@ -0,0 +1,75 @@
/**
* E2E tests for the ObsiGate audio/video viewers (roadmap #109).
*
* Fixtures : `test_vault/sample-audio.mp3` (1 s sine, ~5 ko) +
* `test_vault/sample-video.webm` (64x64 VP8, ~1 ko).
*
* Run (local):
* BASE_URL=http://localhost:2029 npx playwright test tests/e2e/media-viewer.spec.js
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const CREDS = {
username: process.env.OBSIGATE_USER || 'admin',
password: process.env.OBSIGATE_PASS || 'test123',
};
async function login(page) {
await page.goto(BASE);
const loginForm = page.locator('#login-screen');
await expect(loginForm).toBeVisible({ timeout: 5000 }).catch(() => {});
if (await loginForm.isVisible()) {
await page.fill('#login-username', CREDS.username);
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
const treeItem = page.locator(`.tree-item[data-vault="${vault}"][data-path="${filePath}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${vault}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
}
await treeItem.dblclick({ timeout: 5000 });
}
test.describe('Media viewers — HTML5 audio/video (#109)', () => {
test('rend un lecteur audio natif branché sur /api/media', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-audio.mp3');
const audio = page.locator('#content-area .audio-viewer-container audio.audio-player');
await expect(audio).toBeVisible({ timeout: 10000 });
await expect(audio).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(audio).toHaveAttribute('controls', '');
// La durée est lue côté client via loadedmetadata.
await expect(page.locator('#content-area .media-duration-badge')).not.toHaveText('--:--', { timeout: 10000 });
});
test('rend un lecteur vidéo natif branché sur /api/media', async ({ page }) => {
await login(page);
await openFile(page, 'TestVault', 'sample-video.webm');
const video = page.locator('#content-area .video-viewer-container video.video-player');
await expect(video).toBeVisible({ timeout: 10000 });
await expect(video).toHaveAttribute('src', /\/api\/media\/TestVault\?path=/);
await expect(video).toHaveAttribute('playsinline', '');
});
test('le streaming média honore les requêtes Range (206)', async ({ page }) => {
await login(page);
const resp = await page.request.get(
`${BASE}/api/media/TestVault?path=${encodeURIComponent('sample-video.webm')}`,
{ headers: { Range: 'bytes=0-99' } },
);
expect(resp.status()).toBe(206);
expect(resp.headers()['content-range']).toMatch(/^bytes 0-99\/\d+$/);
expect(resp.headers()['accept-ranges']).toBe('bytes');
});
});
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* ObsiGate — Audio/video viewer tests (roadmap #109).
*
* Pure helpers (formatMediaDuration / buildMediaUrl) are evaluated in a VM
* sandbox to avoid importing the full browser-only viewer chain. The rest are
* static checks on the source, CSS and backend wiring.
*
* Usage: node tests/frontend/media-viewer.test.mjs
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import vm from "node:vm";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dirname, "..", "..");
const viewer = readFileSync(path.join(ROOT, "frontend", "js", "viewer.js"), "utf8");
const utils = readFileSync(path.join(ROOT, "frontend", "js", "utils.js"), "utf8");
const css = readFileSync(path.join(ROOT, "frontend", "style.css"), "utf8");
const sw = readFileSync(path.join(ROOT, "frontend", "sw.js"), "utf8");
const main = readFileSync(path.join(ROOT, "backend", "main.py"), "utf8");
const fr = readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8");
const en = readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8");
function test(label, fn) {
try {
fn();
console.log(" \u2713 " + label);
} catch (err) {
console.error(" \u2717 " + label + "\n " + String(err.message).slice(0, 300));
process.exitCode = 1;
}
}
// ── Pure helpers (VM sandbox) ──────────────────────────────────────────────
const begin = viewer.indexOf("export function formatMediaDuration");
const end = viewer.indexOf("function buildDownloadUrl");
assert.ok(begin !== -1 && end > begin, "media viewer helper block not found");
const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(
`${viewer.slice(begin, end).replace(/\bexport /g, "")}\nglobalThis.formatMediaDuration = formatMediaDuration; globalThis.buildMediaUrl = buildMediaUrl;`,
sandbox,
);
const { formatMediaDuration, buildMediaUrl } = sandbox;
test("formatMediaDuration renders m:ss and h:mm:ss", () => {
assert.equal(formatMediaDuration(0), "0:00");
assert.equal(formatMediaDuration(65), "1:05");
assert.equal(formatMediaDuration(3725), "1:02:05");
assert.equal(formatMediaDuration(NaN), "");
assert.equal(formatMediaDuration(-1), "");
});
test("buildMediaUrl encodes vault and path", () => {
const url = buildMediaUrl("My Vault", "café clip.mp4");
assert.equal(url, "/api/media/My%20Vault?path=caf%C3%A9%20clip.mp4");
});
// ── Static checks: viewer wiring ───────────────────────────────────────────
test("viewer.js dispatches is_audio / is_video to the media viewers", () => {
assert.match(viewer, /if \(data\.is_audio\) \{\s*renderAudioViewer\(area, data\);/);
assert.match(viewer, /if \(data\.is_video\) \{\s*renderVideoViewer\(area, data\);/);
});
test("audio viewer uses a native <audio> element and stream URL", () => {
const start = viewer.indexOf("export function renderAudioViewer");
const stop = viewer.indexOf("export function renderVideoViewer");
assert.ok(start !== -1 && stop > start, "renderAudioViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("audio"/);
assert.match(block, /controls: ""/);
assert.match(block, /data\.stream_url \|\| buildMediaUrl/);
});
test("video viewer uses a native <video> element with playsinline", () => {
const start = viewer.indexOf("export function renderVideoViewer");
const stop = viewer.indexOf("export function renderFile");
assert.ok(start !== -1 && stop > start, "renderVideoViewer block not found");
const block = viewer.slice(start, stop);
assert.match(block, /el\("video"/);
assert.match(block, /playsinline: ""/);
});
test("unsupported codec triggers the binary fallback UI", () => {
assert.match(viewer, /audio\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /video\.addEventListener\("error",\s*\(\)\s*=>\s*\{\s*renderMediaFallback/);
assert.match(viewer, /export function renderMediaFallback/);
});
test("playback is stopped when the content area is re-rendered", () => {
assert.match(viewer, /_mediaViewerCleanup/);
assert.match(viewer, /_mediaViewerCleanup\(\); _mediaViewerCleanup = null;/);
});
// ── Static checks: CSS ─────────────────────────────────────────────────────
test("style.css defines the audio/video viewer layout", () => {
assert.match(css, /\.audio-viewer-container/);
assert.match(css, /\.audio-stage\s*\{/);
assert.match(css, /\.audio-artwork\s*\{/);
assert.match(css, /\.video-stage\s*\{/);
assert.match(css, /\.video-player\s*\{/);
});
// ── Static checks: icons, i18n, service worker ─────────────────────────────
test("utils.js maps media extensions to audio-lines / video icons", () => {
assert.match(utils, /"\.mp3": "audio-lines"/);
assert.match(utils, /"\.flac": "audio-lines"/);
assert.match(utils, /"\.mp4": "video"/);
assert.match(utils, /"\.webm": "video"/);
});
test("media viewer strings exist in FR and EN", () => {
for (const key of ["viewer.media_unsupported", "viewer.media_too_large"]) {
assert.match(fr, new RegExp(`"${key}"`));
assert.match(en, new RegExp(`"${key}"`));
}
});
test("service worker never caches streamed media", () => {
assert.match(sw, /startsWith\('\/api\/media\/'\)/);
assert.match(sw, /endsWith\('\/thumb'\)/);
});
// ── Static checks: backend ─────────────────────────────────────────────────
test("backend exposes /api/media and the shared Range helper", () => {
assert.match(main, /@app\.get\("\/api\/media\/\{vault_name\}"/);
assert.match(main, /def _stream_file_with_range\(/);
assert.match(main, /AUDIO_EXTENSIONS|is_audio/);
assert.match(main, /is_audio\(ext\) or is_video\(ext\)/);
});
if (process.exitCode) {
console.error("\nMedia viewer tests FAILED");
} else {
console.log("\nAll media viewer tests passed.");
}
+91
View File
@@ -0,0 +1,91 @@
# tests/test_media_indexing.py — Audio/video in the tree & index (roadmap #109-A/F2)
import asyncio
from pathlib import Path
from backend.indexer import _index_single_file_sync, _scan_vault
from backend.services.vaults import browse_directory
from backend.watcher import VaultEventHandler
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
def _make_vault(tmp_path: Path) -> Path:
vault = tmp_path / "Vault"
vault.mkdir()
(vault / "note.md").write_text("# Note\n", encoding="utf-8")
(vault / "note-vocale.mp3").write_bytes(MEDIA_BYTES)
(vault / "clip.mp4").write_bytes(MEDIA_BYTES)
assets = vault / "assets"
assets.mkdir()
(assets / "ambiance.flac").write_bytes(MEDIA_BYTES)
return vault
class TestScanVaultMedia:
def test_audio_video_indexed_without_content(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
by_path = {f["path"]: f for f in result["files"]}
for path in ("note-vocale.mp3", "clip.mp4", "assets/ambiance.flac"):
assert path in by_path, path
entry = by_path[path]
assert entry["content"] == "" # never read the bytes (#109-A1)
assert entry["content_preview"] == ""
assert entry["size"] > 0
def test_media_present_in_paths(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
files = {p["path"] for p in result["paths"] if p["type"] == "file"}
assert "note-vocale.mp3" in files
assert "clip.mp4" in files
assert "assets/ambiance.flac" in files
def test_no_binary_content_leaks(self, tmp_path):
vault = _make_vault(tmp_path)
result = _scan_vault("V", str(vault))
for f in result["files"]:
if f["extension"] in (".mp3", ".mp4", ".flac"):
assert f["content"] == ""
class TestSingleFileMedia:
def test_metadata_only(self, tmp_path):
vault = _make_vault(tmp_path)
info = _index_single_file_sync("V", str(vault), str(vault / "note-vocale.mp3"))
assert info is not None
assert info["content"] == ""
assert info["extension"] == ".mp3"
assert info["path"] == "note-vocale.mp3"
class TestWatcherRelevance:
def test_media_extensions_relevant(self, tmp_path):
handler = VaultEventHandler("V", asyncio.Queue(), asyncio.new_event_loop())
for name in ("a.mp3", "a.flac", "a.opus", "a.mp4", "a.webm", "a.mov"):
assert handler._is_relevant(str(tmp_path / name)) is True, name
assert handler._is_relevant(str(tmp_path / "a.mkv")) is False
class TestBrowseAndSearch:
def test_browse_lists_media(self, client):
resp = client.get("/api/browse/TestVault")
assert resp.status_code == 200
items = resp.json()["items"]
names = {i["name"]: i for i in items}
assert "sample-audio.mp3" in names
assert names["sample-audio.mp3"]["type"] == "file"
assert names["sample-audio.mp3"]["extension"] == ".mp3"
def test_ext_filter_returns_audio(self, client):
resp = client.get("/api/search/advanced", params={"q": "ext:mp3"})
assert resp.status_code == 200
paths = [r["path"] for r in resp.json()["results"]]
assert any(p.endswith(".mp3") for p in paths)
class TestBrowseService:
def test_browse_directory_service(self, app_with_vault, test_vault_dir):
data = browse_directory("TestVault", "")
names = [i["name"] for i in data["items"]]
assert "sample-audio.mp3" in names
+168
View File
@@ -0,0 +1,168 @@
# tests/test_media_stream.py — Audio/video streaming & HTTP Range (roadmap #109-A/F1)
import base64
from pathlib import Path
import pytest
# 4 KiB of pseudo-ID3 bytes — the endpoint never decodes the payload, it only
# has to serve bytes with the right MIME and honour Range.
MEDIA_BYTES = b"ID3\x03\x00\x00\x00\x00\x00\x00" + bytes(range(256)) * 16
PDF_BYTES = b"%PDF-1.4\n" + b"x" * 2048 + b"\n%%EOF"
def _write(vault_dir: str, name: str, content: bytes) -> None:
(Path(vault_dir) / name).write_bytes(content)
# ═══════════════════════════════════════════════════════════════════
# /api/file — audio/video metadata returned before read_text (#109-A4)
# ═══════════════════════════════════════════════════════════════════
class TestMediaFileView:
def test_audio_metadata(self, client, test_vault_dir):
_write(test_vault_dir, "note-vocale.mp3", MEDIA_BYTES)
resp = client.get("/api/file/TestVault", params={"path": "note-vocale.mp3"})
assert resp.status_code == 200
data = resp.json()
assert data["is_audio"] is True
assert data["is_video"] is not True
assert data["media_mime"] == "audio/mpeg"
assert data["stream_url"] == "/api/media/TestVault?path=note-vocale.mp3"
assert data["size_bytes"] == len(MEDIA_BYTES)
assert data["unsupported"] is not True
def test_video_metadata(self, client, test_vault_dir):
_write(test_vault_dir, "clip.mp4", MEDIA_BYTES)
resp = client.get("/api/file/TestVault", params={"path": "clip.mp4"})
data = resp.json()
assert data["is_video"] is True
assert data["is_audio"] is not True
assert data["media_mime"] == "video/mp4"
assert data["stream_url"] == "/api/media/TestVault?path=clip.mp4"
def test_large_media_falls_back_to_download(self, client, test_vault_dir, monkeypatch):
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
resp = client.get("/api/file/TestVault", params={"path": "huge.mp3"})
data = resp.json()
assert data["unsupported"] is True
assert data["media_too_large"] is True
assert not data.get("is_audio")
# ═══════════════════════════════════════════════════════════════════
# /api/media — byte-range streaming (#109-A2/F1)
# ═══════════════════════════════════════════════════════════════════
class TestMediaStreamRange:
def test_no_range_returns_200_and_accept_ranges(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "song.mp3"})
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-type"].startswith("audio/mpeg")
assert resp.content == MEDIA_BYTES
def test_first_kibibyte_returns_206(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": "bytes=0-1023"},
)
assert resp.status_code == 206
assert resp.headers["accept-ranges"] == "bytes"
assert resp.headers["content-range"] == f"bytes 0-1023/{len(MEDIA_BYTES)}"
assert len(resp.content) == 1024
assert resp.content == MEDIA_BYTES[:1024]
def test_suffix_range(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": "bytes=-100"},
)
assert resp.status_code == 206
assert resp.content == MEDIA_BYTES[-100:]
def test_unsatisfiable_range_returns_416(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get(
"/api/media/TestVault",
params={"path": "song.mp3"},
headers={"Range": f"bytes={len(MEDIA_BYTES) + 10}-"},
)
assert resp.status_code == 416
assert resp.headers["content-range"] == f"bytes */{len(MEDIA_BYTES)}"
def test_mov_mime_override(self, client, test_vault_dir):
_write(test_vault_dir, "clip.mov", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "clip.mov"})
assert resp.headers["content-type"].startswith("video/quicktime")
def test_rejects_non_media(self, client, test_vault_dir):
resp = client.get("/api/media/TestVault", params={"path": "note1.md"})
assert resp.status_code == 400
def test_missing_media_404(self, client):
resp = client.get("/api/media/TestVault", params={"path": "nope.mp3"})
assert resp.status_code == 404
def test_unknown_vault_404(self, client):
resp = client.get("/api/media/NoSuchVault", params={"path": "song.mp3"})
assert resp.status_code == 404
def test_path_traversal_rejected(self, client, test_vault_dir):
_write(test_vault_dir, "song.mp3", MEDIA_BYTES)
resp = client.get("/api/media/TestVault", params={"path": "../../../etc/passwd.mp3"})
assert resp.status_code == 403
def test_media_too_large_returns_413(self, client, test_vault_dir, monkeypatch):
_write(test_vault_dir, "huge.mp3", MEDIA_BYTES)
monkeypatch.setenv("OBSIGATE_MEDIA_MAX_INLINE_MB", "0.000001")
resp = client.get("/api/media/TestVault", params={"path": "huge.mp3"})
assert resp.status_code == 413
# ═══════════════════════════════════════════════════════════════════
# Vault access control (#109-A3)
# ═══════════════════════════════════════════════════════════════════
class TestMediaAccess:
def test_normaluser_cannot_stream_other_vault(self, admin_client):
resp = admin_client.post(
"/api/auth/login", json={"username": "normaluser", "password": "normal123"}
)
assert resp.status_code == 200, resp.text
token = resp.json()["access_token"]
resp = admin_client.get(
"/api/media/OtherVault",
params={"path": "song.mp3"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
# ═══════════════════════════════════════════════════════════════════
# Regression: pdf/stream unchanged after extracting the shared helper (#109-A2)
# ═══════════════════════════════════════════════════════════════════
class TestPdfStreamRegression:
def test_pdf_range_still_works(self, client, test_vault_dir):
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
resp = client.get(
"/api/file/TestVault/pdf/stream",
params={"path": "doc.pdf"},
headers={"Range": "bytes=0-9"},
)
assert resp.status_code == 206
assert resp.headers["content-type"].startswith("application/pdf")
assert resp.headers["content-range"] == f"bytes 0-9/{len(PDF_BYTES)}"
assert resp.content == PDF_BYTES[:10]
def test_pdf_without_range_returns_200(self, client, test_vault_dir):
_write(test_vault_dir, "doc.pdf", PDF_BYTES)
resp = client.get("/api/file/TestVault/pdf/stream", params={"path": "doc.pdf"})
assert resp.status_code == 200
assert resp.headers["accept-ranges"] == "bytes"