Compare commits

...

2 Commits

Author SHA1 Message Date
peter.fong 516bca6de5 state bugs
4 days ago
peter.fong fa2f212e03 inspect_state
4 days ago

@ -137,7 +137,7 @@ docker compose up
docker compose up -d docker compose up -d
docker compose build web && docker compose up web docker compose build --no-cache web && docker compose up web
docker compose build worker_download && docker compose up worker_download docker compose build worker_download && docker compose up worker_download

@ -72,12 +72,18 @@ def dashboard():
logs_list = get_ui_logs() or [] logs_list = get_ui_logs() or []
registered_books = get_registered_books() registered_books = get_registered_books()
log(f"[WEB] Registered books: {registered_books}") log(f"[WEB] Registered books: {registered_books}")
reg = [b for b in get_registered_books() if b.get("status") != "hidden"] from db.repository import fetch_all_books
from pprint import pprint
pprint(fetch_all_books())
pprint(get_registered_books())
# reg = [b for b in get_registered_books() if b.get("status") != "hidden"]
return render_template( return render_template(
"dashboard/dashboard.html", "dashboard/dashboard.html",
books=list_active_books(), books=list_active_books(),
registered=reg, registered=registered_books,
logs=logs_list, logs=logs_list,
) )
@ -102,37 +108,82 @@ def book_detail(book_idx):
@app.route("/init", methods=["POST"]) @app.route("/init", methods=["POST"])
@logcall @logcall
def init_book(): def init_book():
url = request.form.get("url", "").strip() # -------------------------------------------------
# Accept single URL (legacy) OR multi-line URLs
# -------------------------------------------------
raw_urls = request.form.get("urls") or request.form.get("url") or ""
if not url: urls = [line.strip() for line in raw_urls.splitlines() if line.strip()]
if not urls:
return render_template( return render_template(
"dashboard/dashboard.html", "dashboard/dashboard.html",
error="Geen URL opgegeven.", error="Geen URL(s) opgegeven.",
books=list_active_books(), books=list_active_books(),
registered=get_registered_books(), registered=get_registered_books(),
logs=get_ui_logs(), logs=get_ui_logs(),
) )
# -------------------------------------------------
# Duplicate check: existing book_ids
# -------------------------------------------------
existing_books = {b["book_idx"] for b in fetch_all_books()}
results = []
# -------------------------------------------------
# Process each URL independently
# -------------------------------------------------
for url in urls:
try: try:
result = InitService.execute(url) book_id = InitService.derive_book_id(url)
msg = f"Boek geregistreerd: {result.get('title')}"
reg = [b for b in get_registered_books() if b.get("status") != "hidden"] if book_id in existing_books:
results.append(
{
"url": url,
"status": "skipped",
"book_id": book_id,
"message": "Al geregistreerd",
}
)
continue
return render_template( result = InitService.execute(url)
"dashboard/dashboard.html",
message=msg, results.append(
books=list_active_books(), {
registered=reg, "url": url,
logs=get_ui_logs(), "status": "registered",
"book_id": result.get("book_id"),
"title": result.get("title"),
}
) )
except Exception as e: except Exception as e:
log_debug(f"[INIT] ERROR: {e}") log_debug(f"[INIT] ERROR for url={url}: {e}")
results.append(
{
"url": url,
"status": "error",
"error": str(e),
}
)
# -------------------------------------------------
# Summary message
# -------------------------------------------------
ok = sum(1 for r in results if r["status"] == "registered")
skipped = sum(1 for r in results if r["status"] == "skipped")
failed = sum(1 for r in results if r["status"] == "error")
message = f"Geregistreerd: {ok}, overgeslagen: {skipped}, fouten: {failed}"
reg = [b for b in get_registered_books() if b.get("status") != "hidden"] reg = [b for b in get_registered_books() if b.get("status") != "hidden"]
return render_template( return render_template(
"dashboard/dashboard.html", "dashboard/dashboard.html",
error=f"INIT mislukt: {e}", message=message,
init_results=results, # optioneel voor UI-weergave
books=list_active_books(), books=list_active_books(),
registered=reg, registered=reg,
logs=get_ui_logs(), logs=get_ui_logs(),
@ -222,6 +273,18 @@ def abort_download(book_idx):
# ===================================================== # =====================================================
@app.route("/api/state/all", methods=["GET"])
@logcall
def api_state_all():
"""
Returns the merged SQL + Redis state for all books
(same logic as /debug/inspect_state but JSON-only).
"""
from scraper.utils.state_sync import inspect_books_state
return jsonify(inspect_books_state())
@app.route("/api/books") @app.route("/api/books")
@logcall @logcall
def api_books(): def api_books():

@ -202,14 +202,18 @@ def update_book_after_full_scrape(
@logcall @logcall
def get_registered_books(): def get_registered_books():
all_books = sql_fetch_all_books() all_books = sql_fetch_all_books()
return [b for b in all_books if b.get("status") == "registered"] HIDDEN_STATES = {"hidden"}
log(f"[DB] Fetched all books for registered filter, total={len(all_books)}")
return [b for b in all_books if b.get("status") not in HIDDEN_STATES]
@logcall @logcall
def get_active_books(): def get_active_books():
all_books = sql_fetch_all_books() all_books = sql_fetch_all_books()
HIDDEN_STATES = {"hidden", "done"}
log(f"[DB] Fetched all books for active filter, total={len(all_books)}") log(f"[DB] Fetched all books for active filter, total={len(all_books)}")
return [b for b in all_books if b.get("status") in ("active", "downloading")] return [b for b in all_books if b.get("status") not in HIDDEN_STATES]
# ============================================================ # ============================================================
@ -230,7 +234,7 @@ def set_chapters_total(book_idx, total):
log(f"[DB] Setting chapter total for {book_idx} to {total}") log(f"[DB] Setting chapter total for {book_idx} to {total}")
redis_set_chapters_total(book_idx, total) redis_set_chapters_total(book_idx, total)
sql_set_chapters_total(book_idx, total) sql_set_chapters_total(book_idx, total)
_legacy_set_total(book_idx, total) # _legacy_set_total(book_idx, total)
# ============================================================ # ============================================================
@ -240,15 +244,15 @@ def set_chapters_total(book_idx, total):
def inc_download_done(book_idx, amount=1): def inc_download_done(book_idx, amount=1):
log(f"[DB] Incrementing download done for {book_idx} by {amount}") log(f"[DB] Incrementing download done for {book_idx} by {amount}")
redis_inc_download_done(book_idx, amount) redis_inc_download_done(book_idx, amount)
sql_inc_downloaded(book_idx, amount) # sql_inc_downloaded(book_idx, amount)
_legacy_inc_completed(book_idx) # _legacy_inc_completed(book_idx)
@logcall @logcall
def inc_download_skipped(book_idx, amount=1): def inc_download_skipped(book_idx, amount=1):
log(f"[DB] Incrementing download skipped for {book_idx} by {amount}") log(f"[DB] Incrementing download skipped for {book_idx} by {amount}")
redis_inc_download_skipped(book_idx, amount) redis_inc_download_skipped(book_idx, amount)
_legacy_inc_skipped(book_idx) # _legacy_inc_skipped(book_idx)
# ============================================================ # ============================================================
@ -258,7 +262,7 @@ def inc_download_skipped(book_idx, amount=1):
def inc_parsed_done(book_idx, amount=1): def inc_parsed_done(book_idx, amount=1):
log(f"[DB] Incrementing parsed done for {book_idx} by {amount}") log(f"[DB] Incrementing parsed done for {book_idx} by {amount}")
redis_inc_parsed_done(book_idx, amount) redis_inc_parsed_done(book_idx, amount)
sql_inc_parsed(book_idx, amount) # sql_inc_parsed(book_idx, amount)
# ============================================================ # ============================================================
@ -267,7 +271,7 @@ def inc_parsed_done(book_idx, amount=1):
@logcall @logcall
def inc_audio_skipped(book_idx, amount=1): def inc_audio_skipped(book_idx, amount=1):
log(f"[DB] Incrementing audio skipped for {book_idx} by {amount}") log(f"[DB] Incrementing audio skipped for {book_idx} by {amount}")
sql_inc_audio_skipped(book_idx, amount) # sql_inc_audio_skipped(book_idx, amount)
redis_inc_audio_skipped(book_idx, amount) redis_inc_audio_skipped(book_idx, amount)
@ -275,7 +279,7 @@ def inc_audio_skipped(book_idx, amount=1):
def inc_audio_done(book_idx, amount=1): def inc_audio_done(book_idx, amount=1):
log(f"[DB] Incrementing audio done for {book_idx} by {amount}") log(f"[DB] Incrementing audio done for {book_idx} by {amount}")
redis_inc_audio_done(book_idx, amount) redis_inc_audio_done(book_idx, amount)
sql_inc_audio_done(book_idx, amount) # sql_inc_audio_done(book_idx, amount)
# ============================================================ # ============================================================
@ -295,3 +299,58 @@ def inc_parsed(book_idx, amount=1):
@logcall @logcall
def inc_audio_done_legacy(book_idx, amount=1): def inc_audio_done_legacy(book_idx, amount=1):
return inc_audio_done(book_idx, amount) return inc_audio_done(book_idx, amount)
# ============================================================
# READ — DERIVED BOOK STATE
# ============================================================
@logcall
def get_book_state(book_idx):
"""
Canonical read-model for a single book.
Responsibilities:
- Read SQLite snapshot (static metadata)
- Read Redis live state (counters / status)
- Compute derived fields (NO UI logic)
Invariants:
- downloaded = chapters_download_done + chapters_download_skipped
"""
# --- SQLite snapshot ---
sqlite_row = sql_fetch_book(book_idx) or {}
# --- Redis live state ---
key = f"book:{book_idx}:state"
redis_state = _r.hgetall(key) or {}
# Normalize numeric redis values
def _int(v):
try:
return int(v)
except Exception:
return 0
# --- primary counters ---
chapters_done = _int(redis_state.get("chapters_download_done"))
chapters_skipped = _int(redis_state.get("chapters_download_skipped"))
# --- derived counters ---
downloaded = chapters_done + chapters_skipped
# --- build canonical state ---
state = {}
# 1) start with SQLite snapshot
state.update(sqlite_row)
# 2) overlay Redis live fields
state.update(redis_state)
# 3) enforce derived invariants
state["downloaded"] = downloaded
return state

@ -26,6 +26,7 @@ def _key(book_idx: str) -> str:
# STATUS # STATUS
# ------------------------------------------------------------ # ------------------------------------------------------------
def redis_set_status(book_idx: str, status: str): def redis_set_status(book_idx: str, status: str):
log(f"[DB-REDIS] Setting status for {book_idx} to {status}")
key = _key(book_idx) key = _key(book_idx)
r.hset(key, "status", status) r.hset(key, "status", status)
r.hset(key, "last_update", int(time.time())) r.hset(key, "last_update", int(time.time()))
@ -44,6 +45,7 @@ def redis_set_chapters_total(book_idx: str, total: int):
# DOWNLOAD COUNTERS # DOWNLOAD COUNTERS
# ------------------------------------------------------------ # ------------------------------------------------------------
def redis_inc_download_done(book_idx: str, amount: int = 1): def redis_inc_download_done(book_idx: str, amount: int = 1):
log(f"[DB-REDIS] Incrementing download done for {book_idx} by {amount}")
key = _key(book_idx) key = _key(book_idx)
r.hincrby(key, "chapters_download_done", amount) r.hincrby(key, "chapters_download_done", amount)
r.hset(key, "last_update", int(time.time())) r.hset(key, "last_update", int(time.time()))
@ -60,6 +62,7 @@ def redis_inc_download_skipped(book_idx: str, amount: int = 1):
# PARSE COUNTERS # PARSE COUNTERS
# ------------------------------------------------------------ # ------------------------------------------------------------
def redis_inc_parsed_done(book_idx: str, amount: int = 1): def redis_inc_parsed_done(book_idx: str, amount: int = 1):
log(f"[DB-REDIS] Incrementing parsed done for {book_idx} by {amount}")
key = _key(book_idx) key = _key(book_idx)
r.hincrby(key, "chapters_parsed_done", amount) r.hincrby(key, "chapters_parsed_done", amount)
r.hset(key, "last_update", int(time.time())) r.hset(key, "last_update", int(time.time()))
@ -77,10 +80,6 @@ def redis_inc_audio_done(book_idx: str, amount: int = 1):
def redis_inc_audio_skipped(book_idx: str, amount: int = 1): def redis_inc_audio_skipped(book_idx: str, amount: int = 1):
log(f"[DB-REDIS] Incrementing audio skipped for {book_idx} by {amount}") log(f"[DB-REDIS] Incrementing audio skipped for {book_idx} by {amount}")
"""
New: Count skipped audio chapters (timeouts, pre-existing files, abort, etc.)
SQL does NOT track this; Redis-only metric.
"""
key = _key(book_idx) key = _key(book_idx)
r.hincrby(key, "audio_skipped", amount) r.hincrby(key, "audio_skipped", amount)
r.hset(key, "last_update", int(time.time())) r.hset(key, "last_update", int(time.time()))
@ -89,7 +88,7 @@ def redis_inc_audio_skipped(book_idx: str, amount: int = 1):
# ------------------------------------------------------------ # ------------------------------------------------------------
# INITIALISE BOOK STATE # INITIALISE BOOK STATE
# ------------------------------------------------------------ # ------------------------------------------------------------
def init_book_state(book_id: str, title: str, url: str, chapters_total: int): def init_book_state(book_idx: str, title: str, url: str, chapters_total: int):
""" """
Initialiseert de complete Redis state voor een nieuw boek. Initialiseert de complete Redis state voor een nieuw boek.
LET OP: LET OP:
@ -97,7 +96,7 @@ def init_book_state(book_id: str, title: str, url: str, chapters_total: int):
- Alleen missende velden worden toegevoegd. - Alleen missende velden worden toegevoegd.
""" """
key = f"book:{book_id}:state" key = f"book:{book_idx}:state"
# Bestaat al? Dan vullen we alleen missende velden aan. # Bestaat al? Dan vullen we alleen missende velden aan.
exists = r.exists(key) exists = r.exists(key)
@ -105,7 +104,7 @@ def init_book_state(book_id: str, title: str, url: str, chapters_total: int):
pipeline = r.pipeline() pipeline = r.pipeline()
# Basis metadata # Basis metadata
pipeline.hsetnx(key, "book_id", book_id) pipeline.hsetnx(key, "book_id", book_idx)
pipeline.hsetnx(key, "title", title or "") pipeline.hsetnx(key, "title", title or "")
pipeline.hsetnx(key, "url", url or "") pipeline.hsetnx(key, "url", url or "")
@ -126,6 +125,6 @@ def init_book_state(book_id: str, title: str, url: str, chapters_total: int):
pipeline.execute() pipeline.execute()
if exists: if exists:
log(f"[DB-REDIS] init_book_state(): UPDATED existing state for {book_id}") log(f"[DB-REDIS] init_book_state(): UPDATED existing state for {book_idx}")
else: else:
log(f"[DB-REDIS] init_book_state(): CREATED new state for {book_id}") log(f"[DB-REDIS] init_book_state(): CREATED new state for {book_idx}")

@ -72,12 +72,11 @@ def abort_requested(book_idx: str, redis_client=None) -> bool:
port = conn.get("port") port = conn.get("port")
db = conn.get("db") db = conn.get("db")
_debug( _debug(
f"[ABORT_DEBUG] first check book_idx={book_idx} " # f"[ABORT_DEBUG] first check book_idx={book_idx} "
f"redis={host}:{port} db={db}" f"redis={host}:{port} db={db}"
) )
except Exception: except Exception:
_debug(f"[ABORT_DEBUG] first check book_idx={book_idx}") _debug(f"[ABORT_DEBUG] first check book_idx={book_idx}")
_seen_debug_keys.add(key) _seen_debug_keys.add(key)
# Log ACTIVE state # Log ACTIVE state

@ -45,11 +45,24 @@ def detect_volumes(book_base: str):
# ------------------------------------------------------------ # ------------------------------------------------------------
def build_merge_block(title: str, author: str, volumes): def build_merge_block(title: str, author: str, volumes):
lines = [] lines = []
total_vols = len(volumes)
if total_vols >= 100:
pad = 3
elif total_vols >= 10:
pad = 2
else:
pad = 0
for num, dirname in volumes: for num, dirname in volumes:
if pad > 0:
vol_num = f"{num:0{pad}d}"
else:
vol_num = str(num)
line = ( line = (
f'm4b-tool merge --jobs=4 --writer="{author}" ' f'm4b-tool merge --jobs=4 --writer="{author}" '
f'--albumartist="{author}" --album="{title}" ' f'--albumartist="{author}" --album="{title}" '
f'--name="{title}" --output-file="{title}-{num}.m4b" ' f'--name="{title}" --output-file="{title}-{vol_num}.m4b" '
f'"{dirname}" -vvv' f'"{dirname}" -vvv'
) )
lines.append(line) lines.append(line)

@ -153,7 +153,6 @@ def download_chapter(self, payload: dict):
if os.path.exists(save_path): if os.path.exists(save_path):
log_msg(book_idx, f"[DL] SKIP {chapter_num}{save_path}") log_msg(book_idx, f"[DL] SKIP {chapter_num}{save_path}")
inc_download_skipped(book_idx) inc_download_skipped(book_idx)
payload["html"] = None payload["html"] = None
@ -188,8 +187,6 @@ def download_chapter(self, payload: dict):
log_msg(book_idx, f"[DL] OK {chapter_num}: {len(html)} bytes") log_msg(book_idx, f"[DL] OK {chapter_num}: {len(html)} bytes")
inc_download_done(book_idx)
payload["html"] = html payload["html"] = html
payload["skipped"] = False payload["skipped"] = False
payload["path"] = save_path payload["path"] = save_path

@ -46,7 +46,7 @@ def _build_card(sqlite_row, redis_state, merged):
# ============================================================ # ============================================================
# INSPECT ONLY — NO WRITES # INSPECT ONLY — NO WRITES
# ============================================================ # ============================================================
def inspect_books_state(): def inspect_books_state_depecrated():
""" """
Reads all books from SQLite and fetches Redis progress. Reads all books from SQLite and fetches Redis progress.
Builds: Builds:
@ -121,6 +121,88 @@ def inspect_books_state():
return results return results
# ============================================================
# INSPECT ONLY — NO WRITES
# ============================================================
def inspect_books_state():
"""
Reads canonical book state from repository.
Builds:
entry.sqlite
entry.redis
entry.would_merge_to
entry.card (book-card compatible)
"""
from db.repository import get_book_state
from db.db import get_db
db = get_db()
cur = db.cursor()
# Alleen nodig om te weten *welke* books er zijn
cur.execute("SELECT book_idx FROM books")
rows = cur.fetchall()
results = []
for row in rows:
book_idx = row["book_idx"]
# --------------------------------
# Canonical state (ENIGE waarheid)
# --------------------------------
state = get_book_state(book_idx)
# SQLite-view = alleen SQLite-kolommen
sqlite_view = {
k: v
for k, v in state.items()
if k
in (
"book_idx",
"title",
"author",
"description",
"cover_path",
"book_url",
"chapters_total",
"status",
"downloaded",
"parsed",
"audio_done",
"created_at",
"processdate",
"last_update",
)
}
# Redis-view = alleen Redis counters/status
redis_view = {
k: v
for k, v in state.items()
if k.startswith("chapters_")
or k in ("status", "audio_done", "audio_skipped")
}
merged = state # letterlijk de canonieke state
card = _build_card(sqlite_view, redis_view, merged)
results.append(
{
"book_idx": book_idx,
"title": state.get("title"),
"sqlite": sqlite_view,
"redis": redis_view,
"would_merge_to": merged,
"card": card,
}
)
return results
# ============================================================ # ============================================================
# SYNC REDIS → SQLITE (writes) # SYNC REDIS → SQLITE (writes)
# ============================================================ # ============================================================

@ -2,11 +2,11 @@
File: static/css/bookcard.css File: static/css/bookcard.css
Purpose: Purpose:
All styling for registered book cards (book-card) + All styling for registered book cards (book-card) +
status colors + start/abort buttons. status colors + start/abort buttons + progress bars
======================================================================= */ ======================================================================= */
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
GRID WRAPPER FOR REGISTERED BOOKS GRID WRAPPER
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.registered-grid { .registered-grid {
@ -17,7 +17,7 @@
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
MAIN BOOK CARD BOOK CARD
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.book-card { .book-card {
@ -36,7 +36,7 @@
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
BOOK STATUS COLORS STATUS COLORS
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.book-card.processing { .book-card.processing {
@ -55,8 +55,8 @@
} }
.book-card.audio { .book-card.audio {
border-color: #e65100; border-color: #34c759;
box-shadow: 0 0 6px rgba(230, 81, 0, 0.35); box-shadow: 0 0 6px rgba(52, 199, 89, 0.35);
} }
.book-card.completed { .book-card.completed {
@ -70,7 +70,7 @@
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
COVER IMAGE COVER
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.book-cover { .book-cover {
@ -94,7 +94,7 @@
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
META INFORMATION META
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.book-meta { .book-meta {
@ -106,25 +106,30 @@
.book-title { .book-title {
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
margin-bottom: 4px;
} }
.book-author { .book-author {
font-size: 14px; font-size: 14px;
color: #444; color: #444;
margin-bottom: 8px; margin-bottom: 6px;
} }
.book-created { .book-created {
font-size: 12px; font-size: 12px;
color: #666; color: #666;
margin-bottom: 10px;
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
ICON BUTTONS ACTION BUTTONS
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.book-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.icon-btn { .icon-btn {
width: 34px; width: 34px;
height: 34px; height: 34px;
@ -142,7 +147,7 @@
transition: background 0.15s ease, transform 0.1s ease; transition: background 0.15s ease, transform 0.1s ease;
} }
/* Start (green) */ /* Start */
.icon-start { .icon-start {
background: #2d8a3d; background: #2d8a3d;
} }
@ -150,15 +155,13 @@
background: #226c30; background: #226c30;
transform: scale(1.05); transform: scale(1.05);
} }
.icon-start:disabled { .icon-start:disabled {
background: #9bbb9f !important; background: #9bbb9f;
cursor: not-allowed; cursor: not-allowed;
transform: none;
opacity: 0.5; opacity: 0.5;
} }
/* Abort (red) */ /* Abort */
.icon-abort { .icon-abort {
background: #c62828; background: #c62828;
} }
@ -166,36 +169,83 @@
background: #a31f1f; background: #a31f1f;
transform: scale(1.05); transform: scale(1.05);
} }
.icon-abort:disabled { .icon-abort:disabled {
background: #d8a0a0 !important; background: #d8a0a0;
cursor: not-allowed; cursor: not-allowed;
transform: none;
opacity: 0.5; opacity: 0.5;
} }
/* Hide button (gray) */ /* Hide */
.hide-form { .hide-form {
position: absolute; position: absolute;
top: 6px; top: 6px;
right: 6px; right: 6px;
margin: 0;
} }
.icon-hide { .icon-hide {
background: #777; background: #777;
} }
.icon-hide:hover { .icon-hide:hover {
background: #555; background: #555;
transform: scale(1.05);
} }
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
BOOK ACTIONS (right aligned button row) PROGRESS (FULL WIDTH)
----------------------------------------------------------------------- */ ----------------------------------------------------------------------- */
.book-actions { .book-progress {
display: flex; grid-column: 1 / -1;
justify-content: flex-end; /* rechts uitlijnen */
gap: 10px; /* ruimte tussen knoppen */
margin-top: 12px; margin-top: 12px;
padding: 10px 12px;
background: #f6f6f6;
border-radius: 8px;
}
.progress-row {
margin-bottom: 10px;
}
.progress-label {
font-size: 12px;
margin-bottom: 4px;
color: #444;
}
/* BAR */
.progressbar {
position: relative;
width: 100%;
height: 14px;
background: #ddd;
border-radius: 7px;
overflow: hidden;
}
.progressbar-fill {
height: 100%;
transition: width 0.4s ease;
}
/* Download = blauw */
.progressbar-fill.download {
background: #2196f3;
}
/* Audio = groen */
.progressbar-fill.audio {
background: #4caf50;
}
/* TEXT IN BAR */
.progressbar-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
pointer-events: none;
} }

@ -0,0 +1,129 @@
/* ============================================================
File: static/js/bookcard_controller.js
Purpose:
Single owner for updating book-card DOM from merged state
(would_merge_to)
============================================================ */
console.log("[BOOKCARD] controller loaded");
/* ============================================================
ENTRY POINT (called by state_updater.js)
============================================================ */
function updateBookCardsFromState(stateList) {
console.log("[BOOKCARD] updateBookCardsFromState called");
if (!Array.isArray(stateList)) {
console.warn("[BOOKCARD] Invalid stateList", stateList);
return;
}
const stateById = {};
stateList.forEach((entry) => {
const merged = entry.would_merge_to;
if (!merged || merged.book_idx == null) {
console.warn("[BOOKCARD] entry without merged/book_idx", entry);
return;
}
stateById[String(merged.book_idx)] = merged;
});
document.querySelectorAll(".book-card").forEach((card) => {
const bookIdx = card.dataset.bookIdx;
const state = stateById[bookIdx];
if (!state) {
console.debug("[BOOKCARD] No state for book_idx", bookIdx);
return;
}
console.log("[BOOKCARD] Updating card", bookIdx, state.status);
updateSingleBookCard(card, state);
});
}
/* ============================================================
SINGLE CARD UPDATE
============================================================ */
function updateSingleBookCard(card, state) {
console.log("[BOOKCARD] updateSingleBookCard", state.book_idx);
updateStatus(card, state);
updateButtons(card, state);
updateProgress(card, state);
}
/* ============================================================
STATUS
============================================================ */
function updateStatus(card, state) {
console.log("[BOOKCARD][STATUS]", state.book_idx, "→", state.status);
card.className = `book-card ${state.status || ""}`;
}
/* ============================================================
BUTTONS
============================================================ */
function updateButtons(card, state) {
const startBtn = card.querySelector(".icon-start");
const abortBtn = card.querySelector(".icon-abort");
const busy = ["starting", "downloading", "parsing", "audio"];
console.log("[BOOKCARD][BUTTONS]", state.book_idx, "status:", state.status);
if (startBtn) {
// startBtn.disabled = busy.includes(state.status);
}
if (abortBtn) {
abortBtn.disabled = !busy.includes(state.status);
}
}
/* ============================================================
PROGRESS (DOWNLOAD + AUDIO)
============================================================ */
function updateProgress(card, s) {
const total = Number(s.chapters_total || 0);
// const downloadDone =
// Number(s.chapters_download_done || 0) +
// Number(s.chapters_download_skipped || 0);
const downloadDone = Number(s.downloaded || 0);
const audioDone = Number(s.audio_done || 0) + Number(s.audio_skipped || 0);
const downloadPct =
total > 0 ? Math.min((downloadDone / total) * 100, 100) : 0;
const audioPct = total > 0 ? Math.min((audioDone / total) * 100, 100) : 0;
console.log("[BOOKCARD][PROGRESS]", s.book_idx, {
total,
downloadDone,
audioDone,
downloadPct,
audioPct,
});
/* ---- DOWNLOAD ---- */
const dlBar = card.querySelector('[data-field="download_pct"]');
const dlText = card.querySelector('[data-field="download_text"]');
if (dlBar) dlBar.style.width = `${downloadPct}%`;
if (dlText) dlText.textContent = `${downloadDone} / ${total}`;
/* ---- AUDIO ---- */
const auBar = card.querySelector('[data-field="audio_pct"]');
const auText = card.querySelector('[data-field="audio_text"]');
if (auBar) auBar.style.width = `${audioPct}%`;
if (auText) auText.textContent = `${audioDone} / ${total}`;
}

@ -1,247 +1,159 @@
/* ======================================================================= /* =======================================================================
File: static/js/dashboard.js File: static/js/dashboard.js
Purpose: Purpose:
Dashboard interactions: - Sidebar selectie
- Select active book_idx - Start / Abort acties
- Live logs & progress - UI status updates
- Bookcard AJAX start/abort
NOTE: NOTE:
updateLogs() is provided by log_view.js - GEEN polling
- state_updater.js is leidend
======================================================================= */ ======================================================================= */
console.log("[DASHBOARD] loaded");
/* --------------------------------------------------------- /* ---------------------------------------------------------
Utility: Safe fetch wrapper Helpers
--------------------------------------------------------- */ --------------------------------------------------------- */
async function apiGet(url) { async function apiGet(url) {
console.log("[DASHBOARD][API] GET", url);
try { try {
const r = await fetch(url); const r = await fetch(url, { cache: "no-store" });
if (!r.ok) return null; if (!r.ok) {
console.warn("[DASHBOARD][API] GET failed", url, r.status);
return null;
}
return await r.json(); return await r.json();
} catch (e) { } catch (e) {
console.error("API GET failed:", url, e); console.error("[DASHBOARD][API] GET error", url, e);
return null; return null;
} }
} }
function safeUpdateLogs(data) {
if (typeof window.updateLogs === "function") {
console.log("[DASHBOARD] updateLogs()");
window.updateLogs(data);
}
}
/* --------------------------------------------------------- /* ---------------------------------------------------------
Dashboard state State
--------------------------------------------------------- */ --------------------------------------------------------- */
let ACTIVE_BOOK_IDX = null; let ACTIVE_BOOK_IDX = null;
let REFRESH_INTERVAL = null;
console.log(">>> dashboard.js LOADED");
/* --------------------------------------------------------- /* ---------------------------------------------------------
DOM READY DOM READY
--------------------------------------------------------- */ --------------------------------------------------------- */
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
console.log(">>> dashboard.js DOMContentLoaded"); console.log("[DASHBOARD] DOMContentLoaded");
// Fallback: global logs when no active book_idx
setInterval(() => {
if (!ACTIVE_BOOK_IDX) refreshBook(null);
}, 2000);
// Sidebar items bindSidebar();
const items = $$(".book-list-item");
items.forEach((item) => {
item.addEventListener("click", () => {
selectBook(item.dataset.bookIdx);
});
});
// Auto-select first book
if (!ACTIVE_BOOK_IDX && items[0]) {
selectBook(items[0].dataset.bookIdx);
}
// Bind start/abort buttons inside cards
bindBookCardButtons(); bindBookCardButtons();
// Refresh sidebar every few seconds const first = document.querySelector(".book-list-item");
setInterval(refreshActiveBooks, 2800); if (first) {
console.log("[DASHBOARD] auto-select", first.dataset.bookIdx);
selectBook(first.dataset.bookIdx);
}
}); });
/* --------------------------------------------------------- /* ---------------------------------------------------------
Select a book_idx Sidebar
--------------------------------------------------------- */ --------------------------------------------------------- */
function selectBook(bookIdx) { function bindSidebar() {
ACTIVE_BOOK_IDX = bookIdx; console.log("[DASHBOARD] bindSidebar()");
console.log(">>> Selecting book_idx", bookIdx); document.querySelectorAll(".book-list-item").forEach((item) => {
item.onclick = () => selectBook(item.dataset.bookIdx);
// Highlight sidebar
$$(".book-list-item").forEach((el) => {
el.classList.toggle("active", el.dataset.bookIdx === bookIdx);
}); });
// Reset polling
if (REFRESH_INTERVAL) clearInterval(REFRESH_INTERVAL);
REFRESH_INTERVAL = setInterval(() => {
refreshBook(ACTIVE_BOOK_IDX);
}, 2000);
refreshBook(ACTIVE_BOOK_IDX);
} }
/* --------------------------------------------------------- function selectBook(bookIdx) {
Refresh sidebar list if (!bookIdx || bookIdx === ACTIVE_BOOK_IDX) return;
--------------------------------------------------------- */
async function refreshActiveBooks() {
const books = await apiGet("/api/books");
if (!books) return;
const container = $("#book-list");
if (!container) return;
container.innerHTML = "";
books.forEach((b) => {
const div = document.createElement("div");
div.className = "book-list-item";
div.dataset.bookIdx = b.book_idx;
div.innerHTML = ` ACTIVE_BOOK_IDX = bookIdx;
<div class="book-title">${b.title}</div> console.log("[DASHBOARD] selectBook", bookIdx);
<div class="book-status">${b.status}</div>
<div class="book-progress">
${b.download_done}/${b.download_total} downloaded,
${b.audio_done}/${b.audio_total} audio
</div>
`;
div.addEventListener("click", () => selectBook(b.book_idx)); document.querySelectorAll(".book-list-item").forEach((el) => {
container.appendChild(div); el.classList.toggle("active", el.dataset.bookIdx === bookIdx);
}); });
if (!ACTIVE_BOOK_IDX && books.length > 0) { refreshBook(bookIdx);
selectBook(books[0].book_idx);
}
} }
/* --------------------------------------------------------- /* ---------------------------------------------------------
Fetch logs + progress Book refresh (NO POLLING)
--------------------------------------------------------- */ --------------------------------------------------------- */
async function refreshBook(bookIdx) { async function refreshBook(bookIdx) {
if (!bookIdx) { console.log("[DASHBOARD] refreshBook", bookIdx);
const data = await apiGet("/logs");
if (data) updateLogs(data);
return;
}
const state = await apiGet(`/api/book/${bookIdx}/status`);
const logs = await apiGet(`/api/book/${bookIdx}/logs`); const logs = await apiGet(`/api/book/${bookIdx}/logs`);
if (logs) safeUpdateLogs(logs);
if (state) {
updateProgressBars(state);
refreshBookCards(); refreshBookCards();
}
if (logs) updateLogs(logs);
} }
/* --------------------------------------------------------- /* ---------------------------------------------------------
BOOKCARD BUTTON BINDING (idempotent) Bookcard buttons
--------------------------------------------------------- */ --------------------------------------------------------- */
function bindBookCardButtons() { function bindBookCardButtons() {
console.log(">>> bindBookCardButtons() scanning…"); console.log("[DASHBOARD] bindBookCardButtons()");
// START BUTTONS document.querySelectorAll(".icon-start").forEach((btn) => {
document.querySelectorAll(".book-card .icon-start").forEach((btn) => { if (btn.dataset.bound) return;
if (btn.dataset.bound === "1") return;
btn.dataset.bound = "1"; btn.dataset.bound = "1";
btn.addEventListener("click", (ev) => { btn.onclick = (e) => {
ev.preventDefault(); e.preventDefault();
if (btn.disabled) return;
const card = btn.closest(".book-card"); const card = btn.closest(".book-card");
const bookIdx = card?.dataset.bookIdx; if (!card) return;
startBook(card.dataset.bookIdx);
console.log(">>> START clicked:", bookIdx); };
if (!bookIdx) {
console.error(">>> ERROR: bookIdx missing on .book-card dataset");
return;
}
startBook(bookIdx);
});
}); });
// ABORT BUTTONS document.querySelectorAll(".icon-abort").forEach((btn) => {
document.querySelectorAll(".book-card .icon-abort").forEach((btn) => { if (btn.dataset.bound) return;
if (btn.dataset.bound === "1") return;
btn.dataset.bound = "1"; btn.dataset.bound = "1";
btn.addEventListener("click", (ev) => { btn.onclick = (e) => {
ev.preventDefault(); e.preventDefault();
if (btn.disabled) return;
const card = btn.closest(".book-card"); const card = btn.closest(".book-card");
const bookIdx = card?.dataset.bookIdx; if (!card) return;
abortBook(card.dataset.bookIdx);
console.log(">>> ABORT clicked:", bookIdx); };
if (!bookIdx) {
console.error(">>> ERROR: bookIdx missing on .book-card dataset");
return;
}
abortBookAjax(bookIdx);
});
}); });
} }
/* --------------------------------------------------------- /* ---------------------------------------------------------
AJAX START START
--------------------------------------------------------- */ --------------------------------------------------------- */
function startBook(bookIdx) { function startBook(bookIdx) {
console.log(">>> startBook():", bookIdx); console.log("[DASHBOARD] START", bookIdx);
fetch("/start", { fetch("/start", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" }, headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `book_idx=${bookIdx}`, // backend expects field name book_idx body: `book_idx=${bookIdx}`,
}) }).then(() => refreshBook(bookIdx));
.then(async (r) => {
console.log(">>> /start status:", r.status);
let data = null;
try {
data = await r.json();
} catch (e) {}
console.log(">>> /start response:", data);
refreshBookCards();
refreshBook(bookIdx);
})
.catch((err) => console.error("Start failed:", err));
} }
/* --------------------------------------------------------- /* ---------------------------------------------------------
AJAX ABORT ABORT
--------------------------------------------------------- */ --------------------------------------------------------- */
function abortBookAjax(bookIdx) { function abortBook(bookIdx) {
if (!confirm(`Abort tasks for book ${bookIdx}?`)) return; if (!confirm(`Abort book ${bookIdx}?`)) return;
console.log(">>> abortBookAjax():", bookIdx);
fetch(`/abort/${bookIdx}`, { method: "POST" }) console.log("[DASHBOARD] ABORT", bookIdx);
.then(async (r) => {
let data = null;
try {
data = await r.json();
} catch (e) {}
console.log(">>> /abort response:", data);
refreshBookCards(); fetch(`/abort/${bookIdx}`, { method: "POST" }).then(() =>
refreshBook(bookIdx); refreshBook(bookIdx)
}) );
.catch((err) => console.error("Abort failed:", err));
} }
/* --------------------------------------------------------- /* ---------------------------------------------------------
Refresh all book-cards (status, classes, buttons) Bookcard UI refresh (non-progress)
--------------------------------------------------------- */ --------------------------------------------------------- */
async function refreshBookCards() { async function refreshBookCards() {
console.log("[DASHBOARD] refreshBookCards()");
const books = await apiGet("/api/books"); const books = await apiGet("/api/books");
if (!books) return; if (!books) return;
@ -250,22 +162,17 @@ async function refreshBookCards() {
const info = books.find((b) => b.book_idx === idx); const info = books.find((b) => b.book_idx === idx);
if (!info) return; if (!info) return;
// Status CSS console.log("[DASHBOARD] card status", idx, info.status);
card.className = `book-card ${info.status}`; card.className = `book-card ${info.status}`;
// Button states
const startBtn = card.querySelector(".icon-start");
const abortBtn = card.querySelector(".icon-abort"); const abortBtn = card.querySelector(".icon-abort");
if (abortBtn) {
if (startBtn) startBtn.disabled = info.status !== "registered";
if (abortBtn)
abortBtn.disabled = ![ abortBtn.disabled = ![
"processing", "processing",
"downloading", "downloading",
"parsing", "parsing",
"audio", "audio",
].includes(info.status); ].includes(info.status);
}
}); });
bindBookCardButtons();
} }

@ -0,0 +1,101 @@
/* ============================================================
File: static/js/inspect_state.js
Purpose:
- Receive merged state via state_updater.js
- Update ONLY the right-side state tables
- NO polling, NO fetch
============================================================ */
console.log("[inspect_state] JS loaded (subscriber mode)");
/* ------------------------------------------------------------
State subscription
------------------------------------------------------------ */
window.addEventListener("state:update", (e) => {
const entries = e.detail;
if (!Array.isArray(entries)) {
console.warn("[inspect_state] state:update payload is not array", entries);
return;
}
console.log("[inspect_state] state:update received entries:", entries.length);
updateInspectTables(entries);
});
/* ------------------------------------------------------------
Update tables
------------------------------------------------------------ */
function updateInspectTables(entries) {
console.log("[inspect_state] updating tables");
entries.forEach((entry) => {
const bookIdx = entry.book_idx;
if (bookIdx == null) {
console.warn("[inspect_state] entry without book_idx", entry);
return;
}
const block = document.querySelector(
`.state-block[data-book-idx="${bookIdx}"]`
);
if (!block) {
console.warn("[inspect_state] no state-block for book_idx", bookIdx);
return;
}
const table = block.querySelector(".state-table");
if (!table) {
console.warn("[inspect_state] no state-table for book_idx", bookIdx);
return;
}
console.log("[inspect_state] updating table for book_idx", bookIdx);
const sql = entry.sqlite || {};
const redis = entry.redis || {};
const merged = entry.would_merge_to || {};
table.innerHTML = `
<tr>
<th>Field</th>
<th>SQLite</th>
<th>Redis</th>
<th>Merged</th>
</tr>
${row("status", sql, redis, merged)}
${row("chapters_total", sql, redis, merged)}
${row("downloaded", sql, redis, merged)}
${row("chapters_download_done", sql, redis, merged)}
${row("chapters_download_skipped", sql, redis, merged)}
${row("parsed", sql, redis, merged)}
${row("chapters_parsed_done", sql, redis, merged)}
${row("audio_done", sql, redis, merged)}
${row("audio_skipped", sql, redis, merged)}
${row("last_update", sql, redis, merged)}
`;
});
}
/* ------------------------------------------------------------
Row helper
------------------------------------------------------------ */
function row(field, sql, redis, merged) {
const s = sql[field] ?? "";
const r = redis[field] ?? "";
const m = merged[field] ?? "";
const cls = String(s) === String(r) ? "same" : "diff";
return `
<tr>
<th>${field}</th>
<td class="${cls}">${s}</td>
<td class="${cls}">${r}</td>
<td>${m}</td>
</tr>
`;
}

@ -1,72 +0,0 @@
/* =======================================================================
File: static/js/progress.js
Purpose:
Update progress bars dynamically for the current book.
Only updates the main progress box (book_detail page).
======================================================================= */
console.log(">>> progress.js LOADED");
function updateProgressBars(data) {
console.log(">>> progress.js updateProgressBars() CALLED with:", data);
if (!data) {
console.warn(">>> progress.js: NO DATA RECEIVED");
return;
}
// We always update inside the main progress box:
const container = document.querySelector("#progressSection");
if (!container) {
console.warn(">>> progress.js: #progressSection NOT FOUND");
return;
}
// Select bars ONLY inside the correct section
const barDL = container.querySelector(
".progress-bar:not(.audio) .progress-bar-fill"
);
const barAU = container.querySelector(
".progress-bar.audio .progress-bar-fill"
);
const pctDL =
data.download_total > 0
? (100 * data.download_done) / data.download_total
: 0;
const pctAU =
data.audio_total > 0 ? (100 * data.audio_done) / data.audio_total : 0;
if (barDL) {
barDL.style.width = pctDL.toFixed(1) + "%";
console.log(">>> progress.js DL bar =", pctDL.toFixed(1) + "%");
} else {
console.warn(">>> progress.js: barDL NOT FOUND INSIDE #progressSection");
}
if (barAU) {
barAU.style.width = pctAU.toFixed(1) + "%";
console.log(">>> progress.js AU bar =", pctAU.toFixed(1) + "%");
} else {
console.warn(">>> progress.js: barAU NOT FOUND INSIDE #progressSection");
}
// Textual stats — only update inside progress box
const stats = container.querySelectorAll(".progress-stats span");
// Expected: [DL x/y, DL %, AU x/y, AU %]
if (stats.length >= 4) {
stats[0].innerText = `${data.download_done} / ${data.download_total}`;
stats[1].innerText = pctDL.toFixed(1) + "%";
stats[2].innerText = `${data.audio_done} / ${data.audio_total}`;
stats[3].innerText = pctAU.toFixed(1) + "%";
console.log(">>> progress.js stats updated");
} else {
console.warn(
">>> progress.js: not enough stats spans in the container, found",
stats.length
);
}
}

@ -0,0 +1,98 @@
/* ========================================================
File: static/js/state_updater.js
Purpose:
- Poll /api/state/all
- Dispatch merged state to subscribers
(bookcard_controller, inspect_state, others)
- Pause polling when tab inactive
======================================================== */
console.log("[STATE-UPDATER] loaded");
const STATE_POLL_INTERVAL_MS = 2500;
const STATE_ENDPOINT = "/api/state/all";
let STATE_TIMER = null;
/* ========================================================
INIT
======================================================== */
document.addEventListener("DOMContentLoaded", () => {
initStateUpdater();
});
function initStateUpdater() {
const cards = document.querySelectorAll(".book-card");
if (cards.length === 0) {
console.log("[STATE-UPDATER] No bookcards found — skipping");
return;
}
console.log(`[STATE-UPDATER] Starting updater for ${cards.length} bookcards`);
startPolling(true);
document.addEventListener("visibilitychange", () => {
document.hidden ? stopPolling() : startPolling(true);
});
}
/* ========================================================
DISPATCH
======================================================== */
function dispatchState(entries) {
console.debug("[STATE] dispatch", entries.length);
// 1. Bookcards
if (typeof window.updateBookCardsFromState === "function") {
window.updateBookCardsFromState(entries);
}
// 2. Inspect state tables / other subscribers
window.dispatchEvent(new CustomEvent("state:update", { detail: entries }));
}
/* ========================================================
POLLING CONTROL
======================================================== */
function startPolling(immediate = false) {
if (STATE_TIMER) return;
console.log("[STATE-UPDATER] Start polling");
if (immediate) pollState();
STATE_TIMER = setInterval(pollState, STATE_POLL_INTERVAL_MS);
}
function stopPolling() {
if (!STATE_TIMER) return;
console.log("[STATE-UPDATER] Stop polling (tab inactive)");
clearInterval(STATE_TIMER);
STATE_TIMER = null;
}
/* ========================================================
POLL API
======================================================== */
async function pollState() {
if (document.hidden) return;
try {
const resp = await fetch(STATE_ENDPOINT, { cache: "no-store" });
if (!resp.ok) return;
const entries = await resp.json();
if (!Array.isArray(entries)) return;
dispatchState(entries);
} catch (e) {
console.error("[STATE-UPDATER] poll error", e);
}
}

@ -20,7 +20,16 @@
<!-- JS --> <!-- JS -->
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>
<script src="/static/js/log_view.js"></script> <script src="/static/js/log_view.js"></script>
<script src="/static/js/progress.js"></script>
<script src="/static/js/dashboard.js"></script> <script src="/static/js/dashboard.js"></script>
<!-- GLOBAL STATE UPDATER -->
<script src="/static/js/state_updater.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
if (typeof initStateUpdater === "function") {
initStateUpdater();
}
});
</script>
</body> </body>
</html> </html>

@ -1,63 +1,48 @@
{# ============================================================ {# ============================================================ File:
File: templates/components/bookcard.html templates/components/bookcard.html Purpose: Eén enkele boekkaart (dumb
Purpose: component) ============================================================ #}
Eén enkele boekkaart met:
- status styles
- cover
- metadata
- hide button
- start (play)
- abort (stop)
Requires:
variable "b" in context
============================================================ #}
<div class="book-card {{ b.status }}" data-book-idx="{{ b.book_idx }}"> <div class="book-card {{ b.status }}" data-book-idx="{{ b.book_idx }}">
<!-- HIDE -->
<!-- ======================================================
HIDE BUTTON (icon-only)
====================================================== -->
<form <form
action="/hide/{{ b.book_idx }}" action="/hide/{{ b.book_idx }}"
method="POST" method="POST"
onsubmit="return confirm('Dit boek verbergen?')"
class="hide-form" class="hide-form"
onsubmit="return confirm('Dit boek verbergen?')"
> >
<button class="icon-btn icon-hide" title="Verbergen"> <button class="icon-btn icon-hide" title="Verbergen">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button> </button>
</form> </form>
<!-- ====================================================== <!-- COVER -->
COVER
====================================================== -->
<div class="book-cover"> <div class="book-cover">
{% if b.cover_path %} {% if b.cover_path %}
<img src="/{{ b.cover_path }}" alt="cover" class="book-img" /> <img
src="/{{ b.cover_path }}"
class="book-img"
data-field="cover"
alt="cover"
/>
{% else %} {% else %}
<div class="book-img placeholder">?</div> <div class="book-img placeholder" data-field="cover">?</div>
{% endif %} {% endif %}
</div> </div>
<!-- ====================================================== <!-- META -->
META + BUTTONS
====================================================== -->
<div class="book-meta"> <div class="book-meta">
<div class="book-title">{{ b.title }}</div> <div class="book-title" data-field="title">{{ b.title }}</div>
<div class="book-author">{{ b.author }}</div> <div class="book-author" data-field="author">{{ b.author }}</div>
<div class="book-created">Geregistreerd: {{ b.created_at }}</div> <div class="book-created">
Geregistreerd: <span data-field="created_at">{{ b.created_at }}</span>
</div>
<!-- ACTIONS -->
<div class="book-actions"> <div class="book-actions">
<!-- START --> <!-- START -->
<form action="/start" method="POST"> <form action="/start" method="POST">
<input type="hidden" name="book_idx" value="{{ b.book_idx }}" /> <input type="hidden" name="book_idx" value="{{ b.book_idx }}" />
<button <button class="icon-btn icon-start" title="Start" data-action="start">
class="icon-btn icon-start"
title="Start scraping"
{% if b.status != "registered" %}
disabled
{% endif %}
>
<i class="fa-solid fa-play"></i> <i class="fa-solid fa-play"></i>
</button> </button>
</form> </form>
@ -65,18 +50,13 @@
<!-- ABORT --> <!-- ABORT -->
<form action="/abort/{{ b.book_idx }}" method="POST"> <form action="/abort/{{ b.book_idx }}" method="POST">
<input type="hidden" name="book_idx" value="{{ b.book_idx }}" /> <input type="hidden" name="book_idx" value="{{ b.book_idx }}" />
<button <button class="icon-btn icon-abort" title="Abort" data-action="abort">
class="icon-btn icon-abort"
title="Stoppen (abort)"
{% if b.status not in ["processing","downloading","parsing","audio"] %}
disabled
{% endif %}
>
<i class="fa-solid fa-stop"></i> <i class="fa-solid fa-stop"></i>
</button> </button>
</form> </form>
</div> </div>
</div>
</div> <!-- einde .book-meta --> <!-- PROGRESS -->
<div class="book-progress">{% include "components/progress_box.html" %}</div>
</div> <!-- einde .book-card --> </div>

@ -21,7 +21,7 @@
</li> </li>
<li> <li>
<a href="/logs" class="nav-item"> Logs </a> <a href="/debug/inspect_state" class="nav-item"> State overview </a>
</li> </li>
<!-- Tools dropdown --> <!-- Tools dropdown -->

@ -1,62 +1,34 @@
<!-- ======================================================================= <!-- =======================================================================
File: templates/components/progress_box.html File: templates/components/progress_box.html
Purpose: Reusable progress overview (download + audio) for any book. Purpose:
Notes: Dumb progress UI for a book card.
- Expects the following variables from Flask: Initial values via Jinja, live updates via state_updater.js
book_idx: str ======================================================================= -->
title: str
download_total: int
download_done: int
audio_total: int
audio_done: int
- Pure HTML; JS for live updates will be added later.
======================================================================= -->
<div class="progress-box"> <div class="progress-box">
<!-- Header --> <!-- DOWNLOAD -->
<div class="progress-header"> <div class="progress-row">
<h2>Progress</h2> <div class="progress-label">Download</div>
{% if title %} <div class="progressbar">
<div class="progress-subtitle">{{ title }}</div> <div
{% endif %} {% if book_idx %} class="progressbar-fill download"
<div class="progress-bookid">Book IDX: <span>{{ book_idx }}</span></div> data-field="download_pct"
{% endif %} style="width: 0%"
</div> ></div>
<div class="progressbar-text" data-field="download_text">0 / 0</div>
<!-- DOWNLOAD SECTION -->
<div class="progress-section">
<h3>Download Progress</h3>
<div class="progress-bar">
{% set pct = 0 %} {% if download_total > 0 %} {% set pct = (100 *
download_done / download_total) | round(1) %} {% endif %}
<div class="progress-bar-fill" style="width: {{ pct }}%;"></div>
</div>
<div class="progress-stats">
<span>{{ download_done }} / {{ download_total }}</span>
<span>{{ pct }}%</span>
</div> </div>
</div> </div>
<!-- AUDIO SECTION --> <!-- AUDIO -->
<div class="progress-section"> <div class="progress-row">
<h3>Audio Progress</h3> <div class="progress-label">Audio</div>
<div class="progressbar">
<div class="progress-bar audio">
{% set pct2 = 0 %} {% if audio_total > 0 %} {% set pct2 = (100 *
audio_done / audio_total) | round(1) %} {% endif %}
<div <div
class="progress-bar-fill audio-fill" class="progressbar-fill audio"
style="width: {{ pct2 }}%;" data-field="audio_pct"
style="width: 0%"
></div> ></div>
<div class="progressbar-text" data-field="audio_text">0 / 0</div>
</div> </div>
<div class="progress-stats">
<span>{{ audio_done }} / {{ audio_total }}</span>
<span>{{ pct2 }}%</span>
</div> </div>
</div>
<script src="/static/js/progress.js"></script>
</div> </div>

@ -6,16 +6,17 @@
======================================================================= --> ======================================================================= -->
<form method="POST" action="/init" class="url-form"> <form method="POST" action="/init" class="url-form">
<label for="url" class="url-label">Book URL:</label> <label for="urls" class="url-label"> Book URL(s) one per line: </label>
<input <textarea
type="text" id="urls"
id="url" name="urls"
name="url"
class="url-input" class="url-input"
placeholder="https://www.piaotia.com/bookinfo/6/6072.html" rows="5"
placeholder="https://www.piaotia.com/bookinfo/6/6072.html
https://www.piaotia.com/bookinfo/3/3785.html"
required required
/> ></textarea>
<button type="submit" class="btn-primary url-submit">Start Scraping</button> <button type="submit" class="btn-primary url-submit">Register book(s)</button>
</form> </form>

@ -1,7 +1,8 @@
{# ============================================================ File: {# ============================================================ File:
templates/debug/inspect_state.html Purpose: Inspect SQLite vs Redis state per templates/debug/inspect_state.html Purpose: Inspect SQLite vs Redis state per
book_idx. Left side: full book-card UI (same component as dashboard) Right side: book_idx - Initial render via Jinja - Live updates via inspect_state.js -
SQL / Redis / merged comparison table. BookCard is server-rendered and NEVER replaced - Only the right-side state table
is updated dynamically
============================================================ #} {% extends ============================================================ #} {% extends
"layout.html" %} {% block content %} "layout.html" %} {% block content %}
@ -43,53 +44,52 @@ SQL / Redis / merged comparison table.
.same { .same {
color: #9f9 !important; color: #9f9 !important;
} }
.diff { .diff {
color: #ff7b7b !important; color: #ff7b7b !important;
font-weight: bold; font-weight: bold;
} }
.empty {
color: #aaa !important;
font-style: italic;
}
</style> </style>
{% macro cmp(sqlval, redisval) %} {% if (sqlval|string) == (redisval|string) %} <div id="state-container">
<td class="same">{{ sqlval }}</td> {% for entry in results %}
<td class="same">{{ redisval }}</td> <div class="state-block" data-book-idx="{{ entry.book_idx }}">
{% else %} <!-- LEFT: BookCard (server-rendered, NEVER replaced) -->
<td class="diff">{{ sqlval }}</td>
<td class="diff">{{ redisval }}</td>
{% endif %} {% endmacro %} {% for entry in results %}
<div class="state-block">
<!-- LEFT COLUMN: book-card preview -->
<div> <div>
{% with b = entry.card %} {% include "components/bookcard.html" %} {% {% if entry.card %} {% with b = entry.card %} {% include
endwith %} "components/bookcard.html" %} {% endwith %} {% else %}
<strong>{{ entry.book_idx }}</strong>
{% endif %}
</div> </div>
<!-- RIGHT COLUMN: SQL vs Redis comparison --> <!-- RIGHT: State table (updated by JS) -->
<div> <div>
<table class="state-table"> <table class="state-table">
<tr> <tr>
<th>Field</th> <th>Field</th>
<th>SQLite</th> <th>SQLite</th>
<th>Redis</th> <th>Redis</th>
<th>Merged Result</th> <th>Merged</th>
</tr> </tr>
{% set sql = entry.sqlite %} {% set redis = entry.redis %} {% set merged = {% set sql = entry.sqlite %} {% set redis = entry.redis %} {% set merged
entry.would_merge_to %} {% for field in [ "status", "chapters_total", = entry.would_merge_to %} {% for field in [ "status", "chapters_total",
"downloaded", "chapters_download_done", "chapters_download_skipped", "downloaded", "chapters_download_done", "chapters_download_skipped",
"parsed", "chapters_parsed_done", "audio_done", "audio_skipped", "parsed", "chapters_parsed_done", "audio_done", "audio_skipped",
"last_update" ] %} "last_update" ] %}
<tr> <tr>
<th>{{ field }}</th> <th>{{ field }}</th>
<td>{{ sql.get(field, '') }}</td> <td>{{ sql.get(field, "") }}</td>
<td>{{ redis.get(field, '') }}</td> <td>{{ redis.get(field, "") }}</td>
<td>{{ merged.get(field, '') }}</td> <td>{{ merged.get(field, "") }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
</div>
{% endfor %}
</div> </div>
{% endfor %} {% endblock %}
{% endblock %} {% block scripts %}
<script src="/static/js/inspect_state.js"></script>
{% endblock %}

@ -32,6 +32,11 @@
<footer class="footer"> <footer class="footer">
BookScraper © 2025 — Powered by Celery + Redis BookScraper © 2025 — Powered by Celery + Redis
</footer> </footer>
{% block scripts %}{% endblock %}
<script src="/static/js/bookcard_controller.js"></script>
<script src="/static/js/state_updater.js"></script>
<script src="/static/js/dashboard.js"></script>
<!-- GLOBAL APP LOGIC (altijd als laatste) --> <!-- GLOBAL APP LOGIC (altijd als laatste) -->
<script src="/static/js/app.js"></script> <script src="/static/js/app.js"></script>

@ -32,6 +32,7 @@
font-size: 13px; font-size: 13px;
} }
/* NEW: Clear button */
#clearLogBtn { #clearLogBtn {
margin-bottom: 10px; margin-bottom: 10px;
padding: 8px 16px; padding: 8px 16px;
@ -65,11 +66,12 @@
display: none; display: none;
} }
</style> </style>
s
</head> </head>
<body> <body>
<a href="/">&larr; Terug</a> <a href="/">&larr; Terug</a>
<h1>Scrape Resultaat</h1> <h1>Scrape Resultaat--</h1>
{% if error %} {% if error %}
<div <div
@ -78,9 +80,7 @@
> >
<strong>Fout:</strong> {{ error }} <strong>Fout:</strong> {{ error }}
</div> </div>
{% endif %} {% endif %} {% if message %}
{% if message %}
<div class="box">{{ message }}</div> <div class="box">{{ message }}</div>
{% endif %} {% endif %}
@ -114,29 +114,127 @@
class="box hidden" class="box hidden"
style="background: #ffefef; border-left: 5px solid #cc0000" style="background: #ffefef; border-left: 5px solid #cc0000"
> >
<strong>Mislukte hoofdstukken:</strong> <strong>Failed chapters:</strong>
<ul id="failedList" style="margin-top: 10px"></ul> <ul id="failedList" style="margin-top: 10px"></ul>
</div> </div>
<div class="box"> <div class="box">
<strong>Live log:</strong><br /> <strong>Live log:</strong><br />
<!-- NEW BUTTON -->
<button id="clearLogBtn" onclick="clearLogs()">Clear logs</button> <button id="clearLogBtn" onclick="clearLogs()">Clear logs</button>
<div id="logbox" class="logbox"></div> <div id="logbox" class="logbox"></div>
</div> </div>
<script> <script>
// komt terug van Celery-scraping task
const scrapingTaskId = "{{ scraping_task_id or '' }}"; const scrapingTaskId = "{{ scraping_task_id or '' }}";
let bookIdx = null; let bookId = null;
let polling = true; let polling = true;
if (scrapingTaskId) pollForBookIdx(); if (scrapingTaskId) pollForBookId();
function pollForBookId() {
fetch(`/celery-result/${scrapingTaskId}`)
.then((r) => r.json())
.then((data) => {
if (data.ready && data.result && data.result.book_id) {
bookId = data.result.book_id;
startLiveUI();
} else setTimeout(pollForBookId, 800);
})
.catch(() => setTimeout(pollForBookId, 1200));
}
function startLiveUI() {
document.getElementById("statusBox").classList.remove("hidden");
document.getElementById("abortBtn").classList.remove("hidden");
document.getElementById("abortBtn").onclick = () => {
fetch(`/abort/${bookId}`, { method: "POST" });
};
pollProgress();
pollLogs();
}
function pollProgress() {
if (!bookId) return;
fetch(`/progress/${bookId}`)
.then((r) => r.json())
.then((p) => {
const done = p.completed || 0;
const total = p.total || 0;
document.getElementById(
"progressText"
).innerText = `Completed: ${done} / ${total} | Skipped: ${
p.skipped || 0
} | Failed: ${p.failed || 0}`;
const failedBox = document.getElementById("failedBox");
const failedList = document.getElementById("failedList");
if (p.failed_list && p.failed_list.length > 0) {
failedBox.classList.remove("hidden");
failedList.innerHTML = "";
p.failed_list.forEach((entry) => {
const li = document.createElement("li");
li.textContent = entry;
failedList.appendChild(li);
});
}
if (p.abort) {
document.getElementById("statusLine").innerText = "ABORTED";
polling = false;
} else if (done >= total && total > 0) {
document.getElementById("statusLine").innerText = "KLAAR ✔";
polling = false;
} else {
document.getElementById("statusLine").innerText = "Bezig…";
}
if (polling) setTimeout(pollProgress, 1000);
})
.catch(() => {
if (polling) setTimeout(pollProgress, 1500);
});
}
function pollLogs() {
if (!polling) return;
fetch(`/logs`)
.then((r) => r.json())
.then((data) => {
const logbox = document.getElementById("logbox");
logbox.innerHTML = "";
data.logs.forEach((line) => {
const div = document.createElement("div");
div.textContent = line;
logbox.appendChild(div);
});
logbox.scrollTop = logbox.scrollHeight;
setTimeout(pollLogs, 1000);
})
.catch(() => setTimeout(pollLogs, 1500));
}
// ----------------------------------------------------- // =========================================================
// Vraag Celery-result op, wacht tot de scraper een book_idx teruggeeft // NEW: Clear logs button handler
// ----------------------------------------------------- // =========================================================
function pollForBookIdx() { function clearLogs() {
fetch(`/celery-result/${scrapingTask fetch("/clear-logs", { method: "POST" })
.then(() => {
document.getElementById("logbox").innerHTML = "";
})
.catch((e) => console.error("Clear logs failed:", e));
}
</script>
</body>
</html>

Loading…
Cancel
Save