add(ui/ux): show file formats in the scan and filter by them

This commit is contained in:
Steppenstreuner
2026-08-31 08:33:24 +02:00
parent f08417cdc0
commit 2e84bc8aca
8 changed files with 134 additions and 1 deletions
+4 -1
View File
@@ -22,6 +22,7 @@ the same mount, otherwise moving turns into copy + delete.
| `WORDARR_CONFIG_DIR` | `/config` | location of the SQLite database | | `WORDARR_CONFIG_DIR` | `/config` | location of the SQLite database |
| `WORDARR_AUDIBLE_REGIONS` | `de,com` | Audible marketplaces, first ranks first | | `WORDARR_AUDIBLE_REGIONS` | `de,com` | Audible marketplaces, first ranks first |
| `WORDARR_MUSICBRAINZ_UA` | see `metadata/musicbrainz.py` | user agent MusicBrainz requires | | `WORDARR_MUSICBRAINZ_UA` | see `metadata/musicbrainz.py` | user agent MusicBrainz requires |
| `WORDARR_EXTRA_EBOOK_EXTENSIONS` | | extra ebook formats the scan should offer, e.g. `.kepub,.lit,.rtf` |
| `WORDARR_HARDCOVER_TOKEN` | | Hardcover API token, the best ebook source; without it Hardcover is skipped | | `WORDARR_HARDCOVER_TOKEN` | | Hardcover API token, the best ebook source; without it Hardcover is skipped |
| `WORDARR_GOOGLE_BOOKS_KEY` | | optional, only needed when the keyless Google Books quota runs dry | | `WORDARR_GOOGLE_BOOKS_KEY` | | optional, only needed when the keyless Google Books quota runs dry |
@@ -36,7 +37,9 @@ the same mount, otherwise moving turns into copy + delete.
*Ganze Serie…* requests every episode at once. *Ganze Serie…* requests every episode at once.
3. **Import** scan the download folder. wordarr suggests file→request matches, 3. **Import** scan the download folder. wordarr suggests file→request matches,
which you confirm or correct; entries can be merged, split, or turned into which you confirm or correct; entries can be merged, split, or turned into
requests from their folder names. Importing renames and moves the files requests from their folder names. Every row names its format and size, and
the toolbar can filter by format - a Calibre export lists the same book once
per format, and only one of them belongs in the library. Importing renames and moves the files
according to the library's scheme and writes audio tags. according to the library's scheme and writes audio tags.
Imported titles can be edited afterwards; *Speichern & neu ablegen* re-applies Imported titles can be edited afterwards; *Speichern & neu ablegen* re-applies
+39
View File
@@ -686,6 +686,7 @@ $("#scan-btn").addEventListener("click", async () => {
importPage = 0; importPage = 0;
$("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`; $("#scan-info").textContent = `${scan.items.length} Kandidat(en) in ${scan.download_dir}`;
$("#import-toolbar").hidden = scanItems.length === 0; $("#import-toolbar").hidden = scanItems.length === 0;
formatOptions();
renderImportTable(); renderImportTable();
} catch (err) { } catch (err) {
$("#scan-info").textContent = ""; $("#scan-info").textContent = "";
@@ -698,6 +699,7 @@ $("#scan-btn").addEventListener("click", async () => {
function applyImportView() { function applyImportView() {
const text = $("#import-filter-text").value.toLowerCase(); const text = $("#import-filter-text").value.toLowerCase();
const mode = $("#import-filter-mode").value; const mode = $("#import-filter-mode").value;
const format = $("#import-filter-format").value;
const sort = $("#import-sort").value; const sort = $("#import-sort").value;
viewIdx = scanItems viewIdx = scanItems
.map((_, i) => i) .map((_, i) => i)
@@ -707,12 +709,44 @@ function applyImportView() {
if (mode === "suggested" && !item.suggested_request_id) return false; if (mode === "suggested" && !item.suggested_request_id) return false;
if (mode === "unassigned" && item.request_id) return false; if (mode === "unassigned" && item.request_id) return false;
if (mode === "conflict" && !item.conflict) return false; if (mode === "conflict" && !item.conflict) return false;
if (format && !(item.formats || []).includes(format)) return false;
return true; return true;
}); });
if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score); if (sort === "score") viewIdx.sort((a, b) => scanItems[b].score - scanItems[a].score);
if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name)); if (sort === "name") viewIdx.sort((a, b) => scanItems[a].name.localeCompare(scanItems[b].name));
} }
// the split of a folder happens client side, so the extension comes off the name
function extOf(path) {
const ext = (path.split("/").pop().match(/\.([^.]+)$/) || [])[1];
return ext ? [ext.toLowerCase()] : [];
}
function humanSize(bytes) {
if (!bytes) return "";
const units = ["B", "KB", "MB", "GB"];
let n = bytes, u = 0;
while (n >= 1024 && u < units.length - 1) { n /= 1024; u++; }
return `${n.toFixed(n < 10 && u > 0 ? 1 : 0).replace(".", ",")} ${units[u]}`;
}
// a Calibre export ships one book in a dozen formats, so the row has to say
// which one it is before anything can be picked
function formatTag(item) {
const formats = item.formats || [];
if (!formats.length) return "";
const size = humanSize(item.size);
return `<span class="tag fmt">${esc(formats.join(", "))}${size ? " · " + size : ""}</span>`;
}
function formatOptions() {
const all = [...new Set(scanItems.flatMap((it) => it.formats || []))].sort();
const current = $("#import-filter-format").value;
$("#import-filter-format").innerHTML =
'<option value="">Alle Formate</option>' +
all.map((f) => `<option value="${esc(f)}" ${f === current ? "selected" : ""}>.${esc(f)}</option>`).join("");
}
function scoreCell(item) { function scoreCell(item) {
if (!item.suggested_request_id) return '<td class="score-none">—</td>'; if (!item.suggested_request_id) return '<td class="score-none">—</td>';
const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo"; const cls = item.score >= 80 ? "score-hi" : item.score >= 50 ? "score-mid" : "score-lo";
@@ -757,6 +791,7 @@ function renderImportTable() {
<td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td> <td><input type="checkbox" data-check="${i}" ${item.checked ? "checked" : ""}></td>
<td class="mono"> <td class="mono">
${esc(item.name)}${item.is_dir ? " 📁" : ""} ${esc(item.name)}${item.is_dir ? " 📁" : ""}
${formatTag(item)}
${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""} ${item.parts ? `<span class="tag">${item.parts.length} Teile · ${item.files.length} Dateien</span>` : ""}
${!item.parts && item.maybe_separate ${!item.parts && item.maybe_separate
? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>` ? `<span class="tag warn" title="Die ${item.files.length} Dateien tragen verschiedene Titel und sind je groß genug für ein ganzes Buch — mit ✂️ aufteilen">${item.files.length} Titel?</span>`
@@ -876,6 +911,7 @@ $("#import-prev").addEventListener("click", () => { importPage--; renderImportTa
$("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); }); $("#import-next").addEventListener("click", () => { importPage++; renderImportTable(); });
$("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); }); $("#import-filter-text").addEventListener("input", () => { importPage = 0; renderImportTable(); });
$("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); }); $("#import-filter-mode").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-filter-format").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); }); $("#import-sort").addEventListener("change", () => { importPage = 0; renderImportTable(); });
$("#import-select-suggested").addEventListener("click", () => { $("#import-select-suggested").addEventListener("click", () => {
@@ -1053,6 +1089,8 @@ $("#import-merge").addEventListener("click", () => {
media_type: chosen[0].media_type, media_type: chosen[0].media_type,
is_dir: true, is_dir: true,
files: chosen.flatMap((it) => it.files), files: chosen.flatMap((it) => it.files),
formats: [...new Set(chosen.flatMap((it) => it.formats || []))].sort(),
size: chosen.reduce((n, it) => n + (it.size || 0), 0),
checked: true, checked: true,
request_id: chosen.find((it) => it.request_id)?.request_id ?? null, request_id: chosen.find((it) => it.request_id)?.request_id ?? null,
suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null, suggested_request_id: chosen.find((it) => it.suggested_request_id)?.suggested_request_id ?? null,
@@ -1079,6 +1117,7 @@ function splitItem(i) {
media_type: item.media_type, media_type: item.media_type,
is_dir: false, is_dir: false,
files: [f], files: [f],
formats: extOf(f),
checked: false, checked: false,
request_id: null, request_id: null,
suggested_request_id: null, suggested_request_id: null,
+3
View File
@@ -157,6 +157,9 @@
<option value="unassigned">Nur ohne Zuordnung</option> <option value="unassigned">Nur ohne Zuordnung</option>
<option value="conflict">Nur Konflikte</option> <option value="conflict">Nur Konflikte</option>
</select> </select>
<select id="import-filter-format" title="Nur Einträge mit diesem Dateiformat zeigen">
<option value="">Alle Formate</option>
</select>
<select id="import-sort"> <select id="import-sort">
<option value="none">Reihenfolge: Scan</option> <option value="none">Reihenfolge: Scan</option>
<option value="score">Score absteigend</option> <option value="score">Score absteigend</option>
+1
View File
@@ -55,6 +55,7 @@ button:disabled { opacity: 0.6; cursor: default; }
.mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; } .mono { font-family: ui-monospace, monospace; font-size: 0.85rem; word-break: break-all; }
.subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; } .subpath { color: var(--muted); font-size: 0.75rem; font-family: ui-monospace, monospace; }
.tag.warn { background: #8a6d1f; } .tag.warn { background: #8a6d1f; }
.tag.fmt { background: #3a3f4b; color: #cfd4de; }
/* per-entry import feedback */ /* per-entry import feedback */
/* :not([hidden]): a plain display rule would beat the hidden attribute */ /* :not([hidden]): a plain display rule would beat the hidden attribute */
+42
View File
@@ -6,6 +6,8 @@ from pathlib import Path
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from wordarr import config as config_module
@pytest.fixture @pytest.fixture
def client(tmp_path, monkeypatch): def client(tmp_path, monkeypatch):
@@ -1093,3 +1095,43 @@ def test_scan_does_not_loop_on_a_symlink_cycle(client):
(d / "back").symlink_to(client.downloads, target_is_directory=True) (d / "back").symlink_to(client.downloads, target_is_directory=True)
items = scanner.scan(client.downloads) items = scanner.scan(client.downloads)
assert [i["name"] for i in items] == ["book"] assert [i["name"] for i in items] == ["book"]
def test_scan_reports_the_format_of_every_ebook(client):
"""A Calibre export is the same book a dozen times; the rows differ only in
their extension, so the scan has to name it."""
book = "Der Lehrer - Will er dir helfen - McFadden, Freida"
for ext, size in ((".epub", 1200), (".azw3", 1300), (".pdf", 2100)):
(client.downloads / f"{book}{ext}").write_bytes(b"x" * size)
items = client.get("/api/import/scan").json()["items"]
assert {i["formats"][0]: i["size"] for i in items} == {
"epub": 1200, "azw3": 1300, "pdf": 2100,
}
assert all(len(i["formats"]) == 1 for i in items)
def test_scan_lists_every_format_of_an_audiobook_folder(client):
got = client.downloads / "Ein Hoerbuch"
got.mkdir()
(got / "01 Teil.mp3").write_bytes(b"x" * 10)
(got / "02 Teil.m4b").write_bytes(b"x" * 20)
item = client.get("/api/import/scan").json()["items"][0]
assert item["formats"] == ["m4b", "mp3"]
assert item["size"] == 30
def test_extra_ebook_extensions_are_configurable(client, monkeypatch):
"""The exotic Calibre formats stay out of the scan until they are asked for."""
(client.downloads / "Der Lehrer.kepub").write_bytes(b"x")
assert client.get("/api/import/scan").json()["items"] == []
monkeypatch.setenv("WORDARR_EXTRA_EBOOK_EXTENSIONS", ".kepub")
importlib.reload(config_module)
try:
items = client.get("/api/import/scan").json()["items"]
assert [i["formats"] for i in items] == [["kepub"]]
finally:
monkeypatch.delenv("WORDARR_EXTRA_EBOOK_EXTENSIONS")
importlib.reload(config_module)
+18
View File
@@ -28,6 +28,9 @@ def server(tmp_path_factory):
(downloads / "Eine Folge").mkdir(parents=True) (downloads / "Eine Folge").mkdir(parents=True)
for i in (1, 2): for i in (1, 2):
(downloads / "Eine Folge" / f"{i:02d}.mp3").write_bytes(b"") (downloads / "Eine Folge" / f"{i:02d}.mp3").write_bytes(b"")
# one book in three formats, the case the format column exists for
for ext in (".epub", ".azw3", ".pdf"):
(downloads / f"Der Lehrer{ext}").write_bytes(b"x" * 2048)
port = _free_port() port = _free_port()
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "wordarr.main:app", "--port", str(port)], [sys.executable, "-m", "uvicorn", "wordarr.main:app", "--port", str(port)],
@@ -106,3 +109,18 @@ def test_import_dialog_opens_and_closes(page):
page.keyboard.press("Escape") page.keyboard.press("Escape")
page.wait_for_timeout(150) page.wait_for_timeout(150)
assert not page.locator("#quick-dialog").is_visible() assert not page.locator("#quick-dialog").is_visible()
def test_format_tags_and_the_format_filter(page):
page.click("nav button[data-view=import]")
page.click("#scan-btn")
page.wait_for_selector("[data-quick]")
tags = page.locator("#import-table .tag.fmt").all_text_contents()
assert "azw3 · 2,0 KB" in tags
assert "mp3" in " ".join(tags) # the audiobook folder is labelled too
page.select_option("#import-filter-format", "epub")
rows = page.locator("#import-table tbody tr")
assert rows.count() == 1
assert "epub" in rows.first.locator(".tag.fmt").text_content()
+8
View File
@@ -6,6 +6,14 @@ CONFIG_DIR = Path(os.environ.get("WORDARR_CONFIG_DIR", "/config"))
DB_PATH = Path(os.environ.get("WORDARR_DB_PATH", str(CONFIG_DIR / "wordarr.db"))) DB_PATH = Path(os.environ.get("WORDARR_DB_PATH", str(CONFIG_DIR / "wordarr.db")))
EBOOK_EXTENSIONS = {".epub", ".pdf", ".mobi", ".azw3", ".azw", ".fb2", ".djvu"} EBOOK_EXTENSIONS = {".epub", ".pdf", ".mobi", ".azw3", ".azw", ".fb2", ".djvu"}
# a Calibre export drops a dozen more formats next to those; list the ones you
# want the scan to offer, e.g. WORDARR_EXTRA_EBOOK_EXTENSIONS=".kepub,.lit,.rtf"
EBOOK_EXTENSIONS |= {
e if e.startswith(".") else f".{e}"
for e in (x.strip().lower() for x in
os.environ.get("WORDARR_EXTRA_EBOOK_EXTENSIONS", "").split(","))
if e
}
AUDIOBOOK_EXTENSIONS = {".m4b", ".m4a", ".mp3", ".flac", ".ogg", ".opus"} AUDIOBOOK_EXTENSIONS = {".m4b", ".m4a", ".mp3", ".flac", ".ogg", ".opus"}
COMIC_EXTENSIONS = {".cbz", ".cbr", ".cb7"} COMIC_EXTENSIONS = {".cbz", ".cbr", ".cb7"}
ALL_EXTENSIONS = EBOOK_EXTENSIONS | AUDIOBOOK_EXTENSIONS | COMIC_EXTENSIONS ALL_EXTENSIONS = EBOOK_EXTENSIONS | AUDIOBOOK_EXTENSIONS | COMIC_EXTENSIONS
+19
View File
@@ -33,6 +33,19 @@ def media_type_for(ext: str) -> str | None:
return None return None
def file_size(path: Path) -> int:
try:
return path.stat().st_size
except OSError: # a broken symlink still deserves a row
return 0
def formats(files: list[Path]) -> list[str]:
"""Extensions in the entry, without the dot and in a stable order, so the UI
can tell a folder of epubs from one that also holds a pdf."""
return sorted({f.suffix.lower().lstrip(".") for f in files if f.suffix})
def natural_key(name: str) -> list: def natural_key(name: str) -> list:
"""Sort key so "Track 2" comes before "Track 10".""" """Sort key so "Track 2" comes before "Track 10"."""
return [int(p) if p.isdigit() else p.lower() for p in re.split(r"(\d+)", name)] return [int(p) if p.isdigit() else p.lower() for p in re.split(r"(\d+)", name)]
@@ -136,6 +149,8 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
"media_type": "audiobook", "media_type": "audiobook",
"is_dir": True, "is_dir": True,
"files": [encode_path(str(f)) for f in audio + discs], "files": [encode_path(str(f)) for f in audio + discs],
"formats": formats(audio + discs),
"size": sum(file_size(f) for f in audio + discs),
"maybe_separate": False, # disc folders are one book by definition "maybe_separate": False, # disc folders are one book by definition
}) })
return return
@@ -150,6 +165,8 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
"media_type": "audiobook", "media_type": "audiobook",
"is_dir": True, "is_dir": True,
"files": [encode_path(str(f)) for f in audio], "files": [encode_path(str(f)) for f in audio],
"formats": formats(audio),
"size": sum(file_size(f) for f in audio),
"maybe_separate": separate_titles(audio), "maybe_separate": separate_titles(audio),
}) })
return return
@@ -169,6 +186,8 @@ def scan(root: Path, split_dirs: bool = False) -> list[dict]:
"media_type": mt, "media_type": mt,
"is_dir": False, "is_dir": False,
"files": [encode_path(str(entry))], "files": [encode_path(str(entry))],
"formats": formats([entry]),
"size": file_size(entry),
}) })
walk(root) walk(root)