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)