add(ui/ux): show file formats in the scan and filter by them
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user