diff --git a/e2e/write-metadata.spec.ts b/e2e/write-metadata.spec.ts new file mode 100644 index 00000000..d6d2f956 --- /dev/null +++ b/e2e/write-metadata.spec.ts @@ -0,0 +1,259 @@ +import { test, expect, type APIRequestContext, type Page } from "@playwright/test"; +import { execSync } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const REPO = process.cwd(); // uploads/ here is bind-mounted at /app/uploads +const BASE = "http://localhost:8765"; +const STAMP = Date.now(); +const EMAIL = `e2e-meta-${STAMP}@example.com`; +const USERNAME = `e2e-meta-${STAMP}`; +const PASSWORD = "E2e-Playwright-2026!"; + +// Host-side upload dir (bind-mounted into the app container). +const UPLOADS_HOST = path.join(REPO, "uploads", `e2e-meta-${STAMP}`); +const UPLOADS_CONT = `/app/uploads/e2e-meta-${STAMP}`; + +interface FormatSeed { + ext: string; + group: string; + mime: string; + id?: string; +} + +// Every write-capable file type, through the REAL admin UI, verified by +// reading each artifact back independently afterwards (this test IS the +// "choose each file type, then check the file's metadata yourself" +// contract): +// +// epub → embedded OPF splice; verify by parsing the OPF XML +// pdf → Calibre metadata.opf sidecar (lone book); parse XML +// cbz → ComicInfo.xml injected; parse XML +// mp3/flac/m4a/m4b → ffprobe the rewritten tags +const FORMATS: FormatSeed[] = [ + { ext: "epub", group: "ebook", mime: "application/epub+zip" }, + { ext: "pdf", group: "ebook", mime: "application/pdf" }, + { ext: "cbz", group: "comic_archive", mime: "application/zip" }, + { ext: "mp3", group: "audiobook", mime: "audio/mpeg" }, + { ext: "flac", group: "audiobook", mime: "audio/flac" }, + { ext: "m4a", group: "audiobook", mime: "audio/mp4" }, + { ext: "m4b", group: "audiobook", mime: "audio/mp4" }, +]; + +let token = ""; +let libraryID = ""; + +function psql(sql: string): string { + // Collapse whitespace (JSON.stringify would bind newlines as literal + // "\n" through the shell and break the SQL). + const flat = sql.replace(/\s+/g, " "); + return execSync( + `docker exec bookhoard_db psql -U postgres -d bookhoard -tAc ${JSON.stringify(flat)}`, + ) + .toString() + .trim(); +} + +function run(cmd: string): string { + return execSync(cmd, { encoding: "utf8" }).trim(); +} + +function writePython(script: string): void { + run(`python3 - <<'PYEOF'\n${script}\nPYEOF`); +} + +function writeEPUB(dest: string, title: string): void { + const script = `import zipfile, shutil +arc = ${JSON.stringify(dest)} +tmp = arc + ".tmp" +with zipfile.ZipFile(tmp, "w") as z: + z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED) + z.writestr("META-INF/container.xml", '') + z.writestr("OEBPS/content.opf", '' + ${JSON.stringify(title)} + 'urn:uuid:e2e') + z.writestr("OEBPS/c1.xhtml", "chapter") +shutil.move(tmp, arc)`; + writePython(script); +} + +function writeCBZ(dest: string): void { + writePython(`import zipfile, shutil +arc = ${JSON.stringify(dest)} +tmp = arc + ".tmp" +with zipfile.ZipFile(tmp, "w") as z: + z.writestr("page_0001.jpg", "not-a-real-jpeg-but-fine") +shutil.move(tmp, arc)`); +} + +/** Independent artifact readers — no app code involved. The python is + * written to a temp file (never -c through the shell: paths with + * quotes/newlines are unreadable that way). */ +function pythonRead(script: string, absPath: string): string { + const scriptPath = path.join(os.tmpdir(), `e2e-meta-read-${STAMP}.py`); + fs.writeFileSync(scriptPath, script); + return run(`python3 ${JSON.stringify(scriptPath)} ${JSON.stringify(absPath)}`); +} + +function artifactTitle(ext: string, absPath: string): string { + switch (ext) { + case "epub": + return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET +with zipfile.ZipFile(sys.argv[1]) as z: + root = ET.fromstring(z.read('OEBPS/content.opf')) +ns = {'dc': 'http://purl.org/dc/elements/1.1/'} +titles = [t.text or '' for t in root.findall('.//dc:title', ns)] +print(titles[0] if titles else '')`, absPath); + case "cbz": + return pythonRead(`import zipfile, sys, xml.etree.ElementTree as ET +with zipfile.ZipFile(sys.argv[1]) as z: + root = ET.fromstring(z.read('ComicInfo.xml')) +print(root.findtext('Title') or '')`, absPath); + case "pdf": + // one-book dir → the Calibre-style sidecar carries the metadata + return pythonRead(`import os, sys, xml.etree.ElementTree as ET +sidecar = os.path.join(os.path.dirname(sys.argv[1]), 'metadata.opf') +root = ET.parse(sidecar).getroot() +ns = {'dc': 'http://purl.org/dc/elements/1.1/'} +print(root.findtext('.//dc:title', default='', namespaces=ns))`, absPath); + default: + // audio containers: ffprobe the rewritten album tag + return run( + `ffprobe -v error -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 ${JSON.stringify(absPath)}`, + ); + } +} + +async function apiLogin(request: APIRequestContext): Promise { + const res = await request.post("/api/auth/login", { + data: { login: EMAIL, password: PASSWORD }, + }); + expect(res.status(), "login should succeed").toBe(200); + return (await res.json()).access_token as string; +} + +test.describe.configure({ mode: "serial" }); + +test.describe("Write-to-file per format (real admin UI + artifact verification)", () => { + let page: Page; + + test.beforeAll(async ({ browser, request }) => { + fs.mkdirSync(UPLOADS_HOST, { recursive: true }); + + // Fresh user, promoted to admin in the dev DB (the harness cleanup + // sweeps transient example.com users afterwards). + const reg = await request.post("/api/auth/register", { + data: { email: EMAIL, username: USERNAME, password: PASSWORD }, + }); + expect([201, 200, 409]).toContain(reg.status()); + + psql(`UPDATE users SET role='admin' WHERE email='${EMAIL}'`); + token = await apiLogin(request); + expect(token).toBeTruthy(); + + // Test library with a folder at our upload dir. + const libRes = await request.post("/api/libraries", { + headers: { Authorization: `Bearer ${token}` }, + data: { name: `E2E Meta Write ${STAMP}`, type: "ebooks" }, + }); + expect(libRes.status()).toBe(201); + libraryID = ((await libRes.json()) as { id: string }).id; + const foldRes = await request.post(`/api/libraries/${libraryID}/folders`, { + headers: { Authorization: `Bearer ${token}` }, + data: { folder_path: UPLOADS_CONT }, + }); + expect(foldRes.status()).toBe(201); + + // Seed one fixture per format: file on disk + media_items row. + for (const f of FORMATS) { + const rel = `${f.group}/${f.ext}/e2e-fixture.${f.ext}`; + const abs = path.join(UPLOADS_HOST, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + if (f.ext === "epub") writeEPUB(abs, "E2E Original Title"); + else if (f.ext === "cbz") writeCBZ(abs); + else if (f.ext === "pdf") fs.writeFileSync(abs, "%PDF-1.4 e2e stub\n"); + else { + const codec = f.ext === "mp3" ? "libmp3lame" : f.ext === "flac" ? "flac" : "aac"; + run( + `ffmpeg -hide_banner -loglevel error -y -f lavfi -i anullsrc=r=44100:cl=mono -t 2 -c:a ${codec} -metadata title='E2E Original Title' -metadata album='E2E Original Title' ${JSON.stringify(abs)}`, + ); + } + + f.id = psql( + `INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at) + VALUES ('${libraryID}', 'E2E Original Title', '${rel}', '${f.group}', '${f.mime}', 100, NOW(), NOW(), NOW()) + RETURNING id`, + ); + // -tAc prints the id row first, then psql's command tag + // ("INSERT 0 1") last; take the first non-empty line. + f.id = f.id.split("\n").find((l) => l.trim())!.trim(); + expect(f.id).toMatch(/^[0-9a-f-]{36}$/i); + } + + page = await browser.newPage(); + await page.context().addCookies([{ name: "token", value: token, url: BASE }]); + }); + + test.afterAll(async () => { + await page?.close(); + // Best-effort cleanup: the library delete cascades the items; the + // psql fallback + rm sweep anything the API couldn't. + try { + await fetch(`${BASE}/api/libraries/${libraryID}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + } catch { + /* best effort */ + } + fs.rmSync(UPLOADS_HOST, { recursive: true, force: true }); + try { + psql(`DELETE FROM libraries WHERE id='${libraryID}'`); + } catch { + /* already gone */ + } + }); + + for (const f of FORMATS) { + test(`writes metadata into ${f.ext} and the file content verifies`, async () => { + const newTitle = `E2E WRITTEN ${f.ext.toUpperCase()}`; + const abs = path.join(UPLOADS_HOST, f.group, f.ext, `e2e-fixture.${f.ext}`); + expect(f.id).toBeTruthy(); + + // Real admin UI flow: book page → Edit → change title → the + // in-app dialog → Save & write. + await page.goto(`/media/${f.id}`); + await page.click('button:has-text("Edit")'); + const modal = page.locator("#metadata-editor-modal"); + await expect(modal).toBeVisible(); + await page.fill('#metadata-editor-modal input[name="title"]', newTitle); + + await page.click('#metadata-editor-modal button:has-text("Write to file")'); + const dialog = page.locator("#write-confirm-dialog"); + await expect(dialog).toBeVisible(); + await dialog.locator('button:has-text("Save & write")').click(); + + // Wait for the request + inline rescan + page reload to settle. + await page.waitForTimeout(2500); + + // Then verify the FILE's metadata — independently of the app + // (read the artifact back with python/ffprobe, not via the app). + let seen = ""; + let lastErr = ""; + for (let i = 0; i < 20; i++) { + try { + seen = artifactTitle(f.ext, abs); + } catch (e) { + lastErr = String(e); + seen = ""; + } + if (seen.includes(newTitle)) break; + await page.waitForTimeout(500); + } + if (!seen.includes(newTitle)) { + console.error(`[${f.ext}] artifact read failed: ${lastErr}`); + } + expect(seen, `${f.ext}: the FILE must carry the written metadata`).toContain(newTitle); + expect(seen, `${f.ext}: the old title must be replaced`).not.toContain("E2E Original Title"); + }); + } +}); \ No newline at end of file diff --git a/internal/services/metadata_writer.go b/internal/services/metadata_writer.go index c9d2c0d2..ede392d2 100644 --- a/internal/services/metadata_writer.go +++ b/internal/services/metadata_writer.go @@ -417,6 +417,14 @@ func atomicWrite(path string, data []byte) error { return err } tmpPath := tmp.Name() + // 0644: sidecars/rewritten files must be readable by host-side + // tools sharing the volume (Calibre reads metadata.opf); a 0600 + // temp default would lock everyone but the server out. + if err := tmp.Chmod(0o644); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } if _, err := tmp.Write(data); err != nil { tmp.Close() os.Remove(tmpPath)