diff --git a/.githooks/pre-push b/.githooks/pre-push index c83328a..a37bb09 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -20,12 +20,6 @@ while read -r local_ref local_sha remote_ref remote_sha; do branch="$(echo "$remote_ref" | sed 's|refs/heads/||')" expected_local_ref="refs/heads/$branch" - if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then - echo "Blocked: refusing to push '$branch' to origin." >&2 - echo "Push private branches to the 'private' remote instead." >&2 - exit 1 - fi - if [ "$local_ref" != "$expected_local_ref" ]; then echo "Blocked: push local '$local_ref' to matching public branch '$branch' instead of rewriting refs." >&2 exit 1 diff --git a/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py b/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py index ad123ab..715df72 100644 --- a/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py +++ b/alembic/versions/r5s6t7u8v9w0_remove_bookfusion_matched_abs_id.py @@ -6,6 +6,7 @@ """ import sqlalchemy as sa + from alembic import op revision = "r5s6t7u8v9w0" diff --git a/alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py b/alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py new file mode 100644 index 0000000..694ea46 --- /dev/null +++ b/alembic/versions/s1t2u3v4w5x6_add_detected_books_table.py @@ -0,0 +1,46 @@ +"""Add detected_books table. + +Revision ID: s1t2u3v4w5x6 +Revises: r5s6t7u8v9w0 +Create Date: 2026-04-05 +""" + +from typing import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "s1t2u3v4w5x6" +down_revision: str = "r5s6t7u8v9w0" +branch_labels: Sequence[str] | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.create_table( + "detected_books", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("source", sa.String(length=50), nullable=False, server_default="abs"), + sa.Column("source_id", sa.String(length=255), nullable=False), + sa.Column("title", sa.String(length=500), nullable=True), + sa.Column("author", sa.String(length=500), nullable=True), + sa.Column("cover_url", sa.String(length=500), nullable=True), + sa.Column("progress_percentage", sa.Float(), nullable=False, server_default="0"), + sa.Column("first_detected_at", sa.DateTime(), nullable=True), + sa.Column("last_seen_at", sa.DateTime(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=True, server_default="detected"), + sa.Column("matches_json", sa.Text(), nullable=True), + sa.Column("device", sa.String(length=128), nullable=True), + sa.Column("ebook_filename", sa.String(length=500), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("source_id", "source", name="uq_detected_books_source_id_source"), + ) + op.create_index("ix_detected_books_source", "detected_books", ["source"], unique=False) + op.create_index("ix_detected_books_status", "detected_books", ["status"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_detected_books_status", table_name="detected_books") + op.drop_index("ix_detected_books_source", table_name="detected_books") + op.drop_table("detected_books") diff --git a/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py b/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py new file mode 100644 index 0000000..5de936b --- /dev/null +++ b/alembic/versions/t9u0v1w2x3y4_merge_detected_books_head.py @@ -0,0 +1,19 @@ +"""merge detected books migration head + +Revision ID: t9u0v1w2x3y4 +Revises: 5308a8e2c930, s1t2u3v4w5x6 +Create Date: 2026-04-05 +""" + +revision: str = "t9u0v1w2x3y4" +down_revision = ("5308a8e2c930", "s1t2u3v4w5x6") +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/requirements.txt b/requirements.txt index a13ffd8..7d0dec5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,5 +19,5 @@ alembic==1.18.4 python-socketio[client]==5.16.1 h11==0.16.0 mkdocs-material==9.7.4 -mistune==3.2.2 +mistune==3.2.0 pytest-asyncio==1.3.0 diff --git a/scripts/git/README.md b/scripts/git/README.md index 2a33eb9..a663075 100644 --- a/scripts/git/README.md +++ b/scripts/git/README.md @@ -5,10 +5,9 @@ flow with repo-owned tooling. ## Branch Roles -- `dev`: public development branch on `origin` -- `main`: public release branch on `origin` -- `draft`: private unpublished branch on `private` -- `private/dev` and `private/main`: mirrors of the public branches +- `dev`: integration branch on `origin` +- `main`: release branch on `origin` +- feature branches: short-lived topic branches on `origin`, typically merged into `dev` ## First-Time Setup @@ -16,41 +15,41 @@ flow with repo-owned tooling. scripts/git/install-hooks.sh scripts/git/sanitize-public-branch.sh dev "chore: strip private-only files from dev" scripts/git/sanitize-public-branch.sh main "chore: strip private-only files from main" -scripts/git/create-draft-branch.sh -git branch --track draft private/draft scripts/git/setup-worktrees.sh ``` -## Public-First Workflow +## Development Workflow 1. Work on `dev` 2. Push to `origin/dev` -3. Sync the private mirror: +3. Open a PR from `dev` to `main` when ready to release -```bash -scripts/git/sync-private-mirrors.sh dev -``` +## Feature Branch Workflow -## Private-First Workflow - -1. Work on `draft` -2. Push to `private/draft` -3. Promote a sanitized snapshot to `dev` +1. Create a branch from `dev` +2. Push the branch to `origin` +3. Open a PR into `dev` ```bash -scripts/git/promote.sh --push draft dev "feat: publish draft snapshot" -scripts/git/sync-private-mirrors.sh dev +git switch dev +git pull --ff-only origin dev +git switch -c my-feature +git push -u origin my-feature ``` ## Release Workflow +1. Merge feature branches into `dev` +2. Push `dev` +3. Open a PR from `dev` to `main` + ```bash -scripts/git/promote.sh --push dev main "release: vX.Y.Z" -scripts/git/sync-private-mirrors.sh main +git push origin dev +gh pr create --base main --head dev ``` ## Safety Checks - `config/private-paths.txt` defines what must never land on public branches -- `.githooks/pre-push` blocks non-public branches from pushing to `origin` +- `.githooks/pre-push` verifies any branch pushed to `origin` and prompts before direct pushes to `main` - `scripts/git/verify-public-tree.sh` validates public branch content diff --git a/scripts/git/create-draft-branch.sh b/scripts/git/create-draft-branch.sh deleted file mode 100755 index d3c965a..0000000 --- a/scripts/git/create-draft-branch.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/sh - -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -archive_legacy=1 -archive_name="" - -usage() { - cat <<'EOF' -Usage: - scripts/git/create-draft-branch.sh [--no-archive] [--archive-name ] - -Creates private/draft from private/dev and optionally archives private/dev-private. -EOF -} - -while [ $# -gt 0 ]; do - case "$1" in - --no-archive) - archive_legacy=0 - shift - ;; - --archive-name) - archive_name="${2-}" - [ -n "$archive_name" ] || { - usage >&2 - exit 1 - } - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - usage >&2 - exit 1 - ;; - esac -done - -cd "$repo_root" - -git remote get-url private >/dev/null 2>&1 || { - echo "Missing private remote." >&2 - exit 1 -} - -git fetch private --prune >/dev/null 2>&1 - -git rev-parse --verify refs/remotes/private/dev^{commit} >/dev/null 2>&1 || { - echo "Missing private/dev. Sync the private mirror first." >&2 - exit 1 -} - -if git show-ref --verify --quiet refs/remotes/private/draft; then - echo "private/draft already exists." - exit 0 -fi - -draft_sha="$(git rev-parse refs/remotes/private/dev^{commit})" -git push private "$draft_sha:refs/heads/draft" -echo "Created private/draft from private/dev at $draft_sha" - -if [ "$archive_legacy" -eq 1 ] && \ - git show-ref --verify --quiet refs/remotes/private/dev-private; then - if [ -z "$archive_name" ]; then - archive_name="archive/dev-private-$(date +%Y-%m-%d)" - fi - legacy_sha="$(git rev-parse refs/remotes/private/dev-private^{commit})" - git push private "$legacy_sha:refs/heads/$archive_name" - echo "Archived private/dev-private to private/$archive_name" -fi - -echo "Next step: git branch --track draft private/draft" diff --git a/scripts/git/promote.sh b/scripts/git/promote.sh index 0668cdf..320cdfd 100755 --- a/scripts/git/promote.sh +++ b/scripts/git/promote.sh @@ -13,7 +13,7 @@ Usage: scripts/git/promote.sh [--push] [commit message] Examples: - scripts/git/promote.sh draft dev "feat: publish draft snapshot" + scripts/git/promote.sh feature/my-change dev "feat: publish feature snapshot" scripts/git/promote.sh --push dev main "release: v0.2.0" EOF } diff --git a/scripts/git/setup-worktrees.sh b/scripts/git/setup-worktrees.sh index 79cedb4..d21af3e 100755 --- a/scripts/git/setup-worktrees.sh +++ b/scripts/git/setup-worktrees.sh @@ -8,11 +8,6 @@ repo_name="$(basename "$repo_root")" cd "$repo_root" -if ! git show-ref --verify --quiet refs/heads/draft && \ - git show-ref --verify --quiet refs/remotes/private/draft; then - git branch --track draft private/draft >/dev/null 2>&1 || true -fi - active_branches="$(git worktree list --porcelain | awk '/^branch / {sub("^refs/heads/","",$2); print $2}')" add_worktree() { @@ -37,5 +32,5 @@ add_worktree() { git worktree add "$target_dir" "$branch" } -add_worktree draft "$parent_dir/$repo_name-draft" +add_worktree dev "$parent_dir/$repo_name-dev" add_worktree main "$parent_dir/$repo_name-main" diff --git a/scripts/git/sync-private-mirrors.sh b/scripts/git/sync-private-mirrors.sh deleted file mode 100755 index 0105d49..0000000 --- a/scripts/git/sync-private-mirrors.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/sh - -set -eu - -repo_root="$(git rev-parse --show-toplevel)" -force=0 -target="${1-}" - -usage() { - cat <<'EOF' -Usage: - scripts/git/sync-private-mirrors.sh dev - scripts/git/sync-private-mirrors.sh main - scripts/git/sync-private-mirrors.sh --all - scripts/git/sync-private-mirrors.sh --force --all -EOF -} - -while [ $# -gt 0 ]; do - case "$1" in - --all) - target="all" - shift - ;; - --force) - force=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - dev|main) - target="$1" - shift - ;; - *) - usage >&2 - exit 1 - ;; - esac -done - -[ -n "$target" ] || { - usage >&2 - exit 1 -} - -cd "$repo_root" - -git remote get-url origin >/dev/null 2>&1 || { - echo "Missing origin remote." >&2 - exit 1 -} - -git remote get-url private >/dev/null 2>&1 || { - echo "Missing private remote." >&2 - exit 1 -} - -git fetch origin --prune >/dev/null 2>&1 -git fetch private --prune >/dev/null 2>&1 - -sync_branch() { - branch="$1" - origin_ref="refs/remotes/origin/$branch" - private_ref="refs/remotes/private/$branch" - - git rev-parse --verify "$origin_ref^{commit}" >/dev/null 2>&1 || { - echo "Missing origin branch: $branch" >&2 - exit 1 - } - - origin_sha="$(git rev-parse "$origin_ref^{commit}")" - - if git show-ref --verify --quiet "$private_ref"; then - private_sha="$(git rev-parse "$private_ref^{commit}")" - if [ "$origin_sha" = "$private_sha" ]; then - echo "private/$branch already matches origin/$branch" - return - fi - fi - - if [ "$force" -eq 1 ]; then - git push private --force-with-lease="refs/heads/$branch" \ - "$origin_sha:refs/heads/$branch" - else - git push private "$origin_sha:refs/heads/$branch" - fi -} - -if [ "$target" = "all" ]; then - sync_branch dev - sync_branch main - exit 0 -fi - -sync_branch "$target" diff --git a/src/blueprints/api.py b/src/blueprints/api.py index 5fedb9e..b6df694 100644 --- a/src/blueprints/api.py +++ b/src/blueprints/api.py @@ -1,4 +1,4 @@ -"""API blueprint — /api/status, /api/suggestions/*, /api/storyteller/*, /api/grimmory/*. +"""API blueprint — /api/status, /api/detected/*, /api/storyteller/*, /api/grimmory/*. ABS-specific routes (/api/abs/*, /api/cover-proxy/*) are in abs_bp.py. """ @@ -14,9 +14,7 @@ get_database_service, get_grimmory_client, get_kosync_id_for_ebook, - serialize_suggestion, ) -from src.db.models import Book logger = logging.getLogger(__name__) @@ -25,6 +23,63 @@ _VALID_SUGGESTION_SOURCES = ("abs", "kosync", "storyteller", "grimmory") +# ---------------- Detected Books ---------------- + + +@api_bp.route("/api/detected", methods=["GET"]) +def get_detected_books(): + """Return active detected books.""" + database_service = get_database_service() + try: + detected = database_service.get_active_detected_books(limit=50) + results = [] + for d in detected: + results.append( + { + "id": d.id, + "source": d.source, + "source_id": d.source_id, + "title": d.title, + "author": d.author, + "cover_url": d.cover_url, + "progress_percentage": d.progress_percentage, + "first_detected_at": d.first_detected_at.isoformat() if d.first_detected_at else None, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "device": d.device, + "ebook_filename": d.ebook_filename, + "status": d.status, + } + ) + return jsonify({"success": True, "detected": results}) + except Exception as e: + logger.error(f"Failed to get detected books: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@api_bp.route("/api/detected//dismiss", methods=["POST"]) +def dismiss_detected_book(source_id): + """Dismiss a detected book.""" + database_service = get_database_service() + source = request.args.get("source", "abs") + if source not in _VALID_SUGGESTION_SOURCES: + return jsonify({"success": False, "error": "Invalid source"}), 400 + if database_service.dismiss_detected_book(source_id, source=source): + return jsonify({"success": True}) + return jsonify({"success": False, "error": "Not found"}), 404 + + +@api_bp.route("/api/detected//resolve", methods=["POST"]) +def resolve_detected_book(source_id): + """Mark a detected book as resolved (added to library).""" + database_service = get_database_service() + source = request.args.get("source", "abs") + if source not in _VALID_SUGGESTION_SOURCES: + return jsonify({"success": False, "error": "Invalid source"}), 400 + if database_service.resolve_detected_book(source_id, source=source): + return jsonify({"success": True}) + return jsonify({"success": False, "error": "Not found"}), 404 + + # ---------------- Status ---------------- @@ -111,155 +166,7 @@ def api_processing_status(): return jsonify(result) -# ---------------- Suggestions ---------------- - - -@api_bp.route("/api/suggestions", methods=["GET"]) -def get_suggestions(): - database_service = get_database_service() - suggestions = database_service.get_all_actionable_suggestions() - return jsonify([serialize_suggestion(s) for s in suggestions if s.matches]) - - -@api_bp.route("/api/suggestions/rescan", methods=["POST"]) -def rescan_suggestions(): - container = get_container() - data = request.get_json(silent=True) or {} - force = bool(data.get("force")) - stats = container.suggestion_service().request_rescan_library_suggestions(force=force) - return jsonify({"success": True, **stats}) - - -@api_bp.route("/api/suggestions/rescan-status", methods=["GET"]) -def rescan_suggestions_status(): - container = get_container() - status = container.suggestion_service().get_rescan_status() - return jsonify({"success": True, **status}) - - -@api_bp.route("/api/suggestions//hide", methods=["POST"]) -def hide_suggestion(source_id): - database_service = get_database_service() - source = request.args.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - if database_service.hide_suggestion(source_id, source=source): - return jsonify({"success": True}) - return jsonify({"success": False, "error": "Not found"}), 404 - - -@api_bp.route("/api/suggestions//unhide", methods=["POST"]) -def unhide_suggestion(source_id): - database_service = get_database_service() - source = request.args.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - if database_service.unhide_suggestion(source_id, source=source): - return jsonify({"success": True}) - return jsonify({"success": False, "error": "Not found"}), 404 - - -@api_bp.route("/api/suggestions//ignore", methods=["POST"]) -def ignore_suggestion(source_id): - database_service = get_database_service() - source = request.args.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - if database_service.ignore_suggestion(source_id, source=source): - return jsonify({"success": True}) - return jsonify({"success": False, "error": "Not found"}), 404 - - -@api_bp.route("/api/suggestions/clear_stale", methods=["POST"]) -def clear_stale_suggestions(): - database_service = get_database_service() - count = database_service.clear_stale_suggestions() - logger.info(f"Cleared {count} stale suggestions from database") - return jsonify({"success": True, "count": count}) - - -@api_bp.route("/api/suggestions//link-bookfusion", methods=["POST"]) -def link_suggestion_bookfusion(source_id): - database_service = get_database_service() - container = get_container() - data = request.get_json(silent=True) or {} - source = data.get("source", "abs") - if source not in _VALID_SUGGESTION_SOURCES: - return jsonify({"success": False, "error": "Invalid source"}), 400 - - suggestion = database_service.get_pending_suggestion(source_id, source=source) - if not suggestion: - return jsonify({"success": False, "error": "Suggestion not found"}), 404 - - match_index = data.get("match_index") - matches = suggestion.matches or [] - if match_index is None or not isinstance(match_index, int) or match_index < 0 or match_index >= len(matches): - return jsonify({"success": False, "error": "Valid match_index required"}), 400 - - match = matches[match_index] - bookfusion_ids = match.get("bookfusion_ids") or [] - if match.get("source_family") != "bookfusion" or not bookfusion_ids: - return jsonify({"success": False, "error": "Selected match is not a BookFusion candidate"}), 400 - - # Find or create the book to link BookFusion to - if source == "abs": - book = database_service.get_book_by_ref(source_id) - if not book: - abs_client = container.abs_client() - item = abs_client.get_item_details(source_id) if abs_client else None - metadata = (item or {}).get("media", {}).get("metadata", {}) - book = Book( - abs_id=source_id, - title=metadata.get("title") or suggestion.title or source_id, - status="not_started", - duration=(item or {}).get("media", {}).get("duration"), - sync_mode="audiobook", - ) - database_service.save_book(book) - abs_service = container.abs_service() - if abs_service and abs_service.is_available(): - try: - abs_service.add_to_collection(source_id, current_app.config["ABS_COLLECTION_NAME"]) - except Exception as e: - logger.warning(f"Failed to add '{source_id}' to ABS collection during BookFusion link: {e}") - book = database_service.get_book_by_ref(source_id) - else: - # Non-ABS source: look up by the field matching the source type - if source == "storyteller": - book = database_service.get_book_by_storyteller_uuid(source_id) - elif source == "kosync": - book = database_service.get_book_by_kosync_id(source_id) - else: - book = database_service.get_book_by_ebook_filename(source_id) - if not book: - book_kwargs = { - "abs_id": None, - "title": suggestion.title or source_id, - "status": "not_started", - "sync_mode": "ebook_only", - } - if source == "storyteller": - book_kwargs["storyteller_uuid"] = source_id - elif source == "grimmory": - book_kwargs["ebook_filename"] = source_id - elif source == "kosync": - book_kwargs["kosync_doc_id"] = source_id - book = Book(**book_kwargs) - database_service.save_book(book, is_new=True) - book = database_service.get_book_by_id(book.id) - - if not book: - return jsonify({"success": False, "error": "Could not find or create book"}), 500 - - for bid in bookfusion_ids: - database_service.set_bookfusion_book_match_by_book_id(bid, book.id) - database_service.link_bookfusion_highlights_by_book_id(bid, book.id) - - database_service.resolve_suggestion(source_id, source=source) - return jsonify({"success": True, "book_id": book.id}) - - -@api_bp.route("/api/sync-reading-dates", methods=["POST"]) +# ---------------- Storyteller ---------------- def sync_reading_dates_api(): """Auto-complete books at 100% progress and fill missing dates.""" container = get_container() diff --git a/src/blueprints/dashboard.py b/src/blueprints/dashboard.py index 1cf82ae..bc41517 100644 --- a/src/blueprints/dashboard.py +++ b/src/blueprints/dashboard.py @@ -14,11 +14,9 @@ get_container, get_database_service, get_enabled_grimmory_server_ids, - get_hardcover_book_url, - get_service_web_url, - serialize_suggestion, ) from src.utils.cover_resolver import resolve_book_covers +from src.utils.service_url_helper import get_hardcover_book_url, get_service_web_url from src.version import APP_VERSION logger = logging.getLogger(__name__) @@ -391,20 +389,29 @@ def _run_date_sync(): except Exception: pass - # Pending suggestions — for dashboard banner - top_suggestions = [] - suggestions_enabled = os.environ.get("SUGGESTIONS_ENABLED", "false").lower() in ("true", "1", "yes", "on") - if suggestions_enabled: - try: - pending = database_service.get_all_pending_suggestions() - for s in pending[:10]: - serialized = serialize_suggestion(s) - if serialized["top_match"] and serialized["top_match"].get("confidence") == "high": - top_suggestions.append(serialized) - if len(top_suggestions) >= 3: - break - except Exception: - pass + # Active detected books — for dashboard detected section + detected_books = [] + try: + active_detected = database_service.get_active_detected_books(limit=10) + for d in active_detected: + detected_books.append( + { + "id": d.id, + "source": d.source, + "source_id": d.source_id, + "title": d.title, + "author": d.author, + "cover_url": d.cover_url, + "progress_percentage": d.progress_percentage, + "first_detected_at": d.first_detected_at.isoformat() if d.first_detected_at else None, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "matches": d.matches, + "device": d.device, + "ebook_filename": d.ebook_filename, + } + ) + except Exception: + pass return render_template( "index.html", @@ -415,5 +422,5 @@ def _run_date_sync(): grimmory_label=grimmory_label, kosync_unlinked_count=kosync_unlinked_count, unlinked_reading=unlinked_reading, - top_suggestions=top_suggestions, + detected_books=detected_books, ) diff --git a/src/blueprints/helpers.py b/src/blueprints/helpers.py index db8a46a..3f66306 100644 --- a/src/blueprints/helpers.py +++ b/src/blueprints/helpers.py @@ -533,45 +533,6 @@ def restart_server(): os.kill(os.getpid(), signal.SIGTERM) -def _has_bookfusion_evidence(match_dict): - """Check if a match dict has BookFusion-related evidence.""" - if match_dict.get("source_family") == "bookfusion": - return True - return any(ev.startswith("bookfusion") for ev in (match_dict.get("evidence") or [])) - - -def serialize_suggestion(s): - """Shared serializer for PendingSuggestion → JSON-ready dict.""" - matches = [] - for m in s.matches: - # Skip provenance-only entries (e.g. abs_audiobook markers from reverse suggestions) - if m.get("source") == "abs_audiobook" and not m.get("action_kind"): - continue - matches.append( - { - **m, - "evidence": m.get("evidence") or [], - "has_bookfusion": _has_bookfusion_evidence(m), - } - ) - - has_bookfusion_evidence = any(m.get("has_bookfusion") for m in matches) - return { - "id": s.id, - "source_id": s.source_id, - "source": s.source or "unknown", - "title": s.title, - "author": s.author, - "cover_url": s.cover_url, - "matches": matches, - "created_at": s.created_at.isoformat() if s.created_at else None, - "has_bookfusion_evidence": has_bookfusion_evidence, - "top_match": matches[0] if matches else None, - "status": "hidden" if s.status == "dismissed" else s.status, - "hidden": s.status in ("hidden", "dismissed"), - } - - def find_grimmory_metadata(book, grimmory_by_filename): """Find best Grimmory metadata entry for a book by filename.""" for fn in (book.ebook_filename, book.original_ebook_filename): @@ -600,3 +561,21 @@ def safe_folder_name(name: str) -> str: for c in invalid: name = name.replace(c, "_") return name.strip() or "Unknown" + + +def serialize_detected_book(d): + """Serialize DetectedBook for template context.""" + return { + "id": d.id, + "source": d.source, + "source_id": d.source_id, + "title": d.title, + "author": d.author, + "cover_url": d.cover_url, + "progress_percentage": d.progress_percentage, + "first_detected_at": d.first_detected_at.isoformat() if d.first_detected_at else None, + "last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None, + "matches": d.matches, + "device": d.device, + "ebook_filename": d.ebook_filename, + } diff --git a/src/blueprints/matching_bp.py b/src/blueprints/matching_bp.py index fdce3f5..eb019da 100644 --- a/src/blueprints/matching_bp.py +++ b/src/blueprints/matching_bp.py @@ -21,7 +21,6 @@ get_kosync_id_for_ebook, get_manager, get_searchable_ebooks, - serialize_suggestion, ) from src.db.models import Book, StorytellerSubmission from src.services.kosync_service import ensure_kosync_document @@ -226,10 +225,15 @@ def _create_book_mapping( # Resolve suggestions database_service.resolve_suggestion(abs_id) database_service.resolve_suggestion(kosync_doc_id) + # Also resolve detected entries + database_service.resolve_detected_book(abs_id, source="abs") + if kosync_doc_id: + database_service.resolve_detected_book(kosync_doc_id, source="kosync") try: device_doc = database_service.get_kosync_doc_by_filename(ebook_filename) if device_doc and device_doc.document_hash != kosync_doc_id: database_service.resolve_suggestion(device_doc.document_hash) + database_service.resolve_detected_book(device_doc.document_hash, source="kosync") except Exception as e: logger.warning(f"Failed to check/resolve device hash: {e}") @@ -277,34 +281,6 @@ def _build_batch_queue_view(queue): } -@matching_bp.route("/suggestions") -def suggestions(): - """Dedicated page for browsing and acting on pairing suggestions.""" - container = get_container() - database_service = get_database_service() - raw_suggestions = database_service.get_all_actionable_suggestions() - suggestions_list = [serialize_suggestion(s) for s in raw_suggestions if s.matches] - visible_count = sum(1 for s in suggestions_list if not s.get("hidden")) - hidden_count = sum(1 for s in suggestions_list if s.get("hidden")) - suggestions_enabled = current_app.config.get("SUGGESTIONS_ENABLED", False) - bookfusion_enabled = container.bookfusion_client().is_configured() - bookfusion_catalog_count = len(database_service.get_bookfusion_books()) if bookfusion_enabled else 0 - initial_search = request.args.get("search", "").strip() - selected_source_id = request.args.get("source_id", "").strip() - return render_template( - "suggestions.html", - suggestions=suggestions_list, - visible_count=visible_count, - hidden_count=hidden_count, - suggestions_enabled=suggestions_enabled, - bookfusion_enabled=bookfusion_enabled, - bookfusion_catalog_count=bookfusion_catalog_count, - suggestions_data=suggestions_list, - initial_search=initial_search, - selected_source_id=selected_source_id, - ) - - @matching_bp.route("/match", methods=["GET", "POST"]) def match(): container = get_container() @@ -339,6 +315,7 @@ def match(): abs_service.add_to_collection(abs_id, current_app.config["ABS_COLLECTION_NAME"]) attempt_hardcover_automatch(container, book) database_service.resolve_suggestion(abs_id) + database_service.resolve_detected_book(abs_id, source="abs") return redirect(url_for("dashboard.index")) # --- Ebook-only import (no audiobook required) --- @@ -382,8 +359,10 @@ def match(): # Resolve any suggestions involving these source IDs if kosync_doc_id: database_service.resolve_suggestion(kosync_doc_id, source="kosync") + database_service.resolve_detected_book(kosync_doc_id, source="kosync") if storyteller_uuid: database_service.resolve_suggestion(storyteller_uuid, source="storyteller") + database_service.resolve_detected_book(storyteller_uuid, source="storyteller") if ebook_filename: database_service.resolve_suggestion(ebook_filename, source="grimmory") return redirect(url_for("dashboard.index")) @@ -415,6 +394,7 @@ def match(): except Exception as e: logger.warning(f"Grimmory add_to_shelf failed for '{sanitize_log_data(ebook_filename)}': {e}") database_service.resolve_suggestion(kosync_doc_id) + database_service.resolve_detected_book(kosync_doc_id, source="kosync") return redirect(url_for("dashboard.index")) # --- Attach audiobook to ebook-only book --- @@ -463,8 +443,10 @@ def match(): raise attempt_hardcover_automatch(container, new_book) database_service.resolve_suggestion(abs_id) + database_service.resolve_detected_book(abs_id, source="abs") if new_book.kosync_doc_id: database_service.resolve_suggestion(new_book.kosync_doc_id) + database_service.resolve_detected_book(new_book.kosync_doc_id, source="kosync") return redirect(url_for("dashboard.index")) # --- Standard flow (requires audiobook) --- @@ -686,6 +668,7 @@ def batch_match(): abs_service.add_to_collection(item["abs_id"], current_app.config["ABS_COLLECTION_NAME"]) attempt_hardcover_automatch(container, book) database_service.resolve_suggestion(item["abs_id"]) + database_service.resolve_detected_book(item["abs_id"], source="abs") continue # Handle ebook-only queue items @@ -723,6 +706,7 @@ def batch_match(): ensure_kosync_document(book, database_service) if kosync_doc_id: database_service.resolve_suggestion(kosync_doc_id) + database_service.resolve_detected_book(kosync_doc_id, source="kosync") continue book, error = _create_book_mapping( diff --git a/src/db/database_service.py b/src/db/database_service.py index 8c4ccb4..f2d2feb 100644 --- a/src/db/database_service.py +++ b/src/db/database_service.py @@ -10,13 +10,11 @@ from .book_repository import BookRepository from .bookfusion_repository import BookFusionRepository +from .detected_repository import DetectedRepository from .grimmory_repository import GrimmoryRepository from .hardcover_repository import HardcoverRepository from .kosync_repository import KoSyncRepository -from .models import ( - Base, - DatabaseManager, -) +from .models import Base, DatabaseManager from .reading_repository import VALID_JOURNAL_EVENTS, ReadingRepository from .settings_repository import SettingsRepository from .storyteller_repository import StorytellerRepository @@ -66,6 +64,7 @@ def __init__(self, db_path: str): self._kosync = KoSyncRepository(self.db_manager) self._reading = ReadingRepository(self.db_manager) self._suggestions = SuggestionRepository(self.db_manager) + self._detected = DetectedRepository(self.db_manager) self._hardcover = HardcoverRepository(self.db_manager) self._storyteller = StorytellerRepository(self.db_manager) self._bookfusion = BookFusionRepository(self.db_manager) @@ -290,6 +289,7 @@ def get_session(self): "_kosync", "_reading", "_suggestions", + "_detected", "_hardcover", "_storyteller", "_bookfusion", diff --git a/src/db/detected_repository.py b/src/db/detected_repository.py new file mode 100644 index 0000000..4efc9ca --- /dev/null +++ b/src/db/detected_repository.py @@ -0,0 +1,127 @@ +"""Repository for detected external books.""" + +from datetime import UTC, datetime + +from .base_repository import BaseRepository +from .models import DetectedBook + + +class DetectedRepository(BaseRepository): + ACTIVE_STATUSES = ("detected",) + + def get_detected_book(self, source_id, source="abs"): + return self._get_one( + DetectedBook, + DetectedBook.source_id == source_id, + DetectedBook.source == source, + ) + + def get_active_detected_books(self, limit=None): + with self.get_session() as session: + query = ( + session.query(DetectedBook) + .filter(DetectedBook.status.in_(self.ACTIVE_STATUSES)) + .order_by(DetectedBook.last_seen_at.desc()) + ) + if limit is not None: + query = query.limit(limit) + items = query.all() + for item in items: + session.expunge(item) + return items + + def save_detected_book(self, detected_book): + """Upsert a detected book while preserving dismissed status.""" + filters = [ + DetectedBook.source_id == detected_book.source_id, + DetectedBook.source == detected_book.source, + ] + with self.get_session() as session: + existing = session.query(DetectedBook).filter(*filters).first() + now = datetime.now(UTC) + if existing: + if existing.status == "dismissed" and detected_book.status == "detected": + detected_book.status = "dismissed" + + if detected_book.title: + existing.title = detected_book.title + if detected_book.author: + existing.author = detected_book.author + if detected_book.cover_url: + existing.cover_url = detected_book.cover_url + if detected_book.matches_json is not None: + existing.matches_json = detected_book.matches_json + if detected_book.device: + existing.device = detected_book.device + if detected_book.ebook_filename: + existing.ebook_filename = detected_book.ebook_filename + + existing.progress_percentage = detected_book.progress_percentage + existing.status = detected_book.status + existing.last_seen_at = detected_book.last_seen_at or now + if existing.first_detected_at is None: + existing.first_detected_at = detected_book.first_detected_at or now + + session.flush() + session.refresh(existing) + session.expunge(existing) + return existing + + session.add(detected_book) + session.flush() + session.refresh(detected_book) + session.expunge(detected_book) + return detected_book + + def dismiss_detected_book(self, source_id, source="abs"): + with self.get_session() as session: + detected = ( + session.query(DetectedBook) + .filter( + DetectedBook.source_id == source_id, + DetectedBook.source == source, + ) + .first() + ) + if not detected: + return False + detected.status = "dismissed" + detected.last_seen_at = datetime.now(UTC) + return True + + def resolve_detected_book(self, source_id, source="abs"): + with self.get_session() as session: + detected = ( + session.query(DetectedBook) + .filter( + DetectedBook.source_id == source_id, + DetectedBook.source == source, + ) + .first() + ) + if not detected: + return False + detected.status = "resolved" + detected.last_seen_at = datetime.now(UTC) + return True + + def get_all_ebook_filenames(self): + """Get all ebook filenames from detected books with matches.""" + with self.get_session() as session: + results = ( + session.query(DetectedBook) + .filter( + DetectedBook.status.in_(self.ACTIVE_STATUSES), + DetectedBook.matches_json.isnot(None), + ) + .all() + ) + filenames = set() + for detected in results: + matches = detected.matches or [] + for match in matches: + if match.get("filename"): + filenames.add(match["filename"]) + for item in results: + session.expunge(item) + return filenames diff --git a/src/db/models.py b/src/db/models.py index cc43d02..ca10bcf 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -521,6 +521,68 @@ def __repr__(self): return f"" +class DetectedBook(Base): + """Model for external books with real progress that are not yet in PageKeeper.""" + + __tablename__ = "detected_books" + __table_args__ = (UniqueConstraint("source_id", "source", name="uq_detected_books_source_id_source"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + source = Column(String(50), default="abs", nullable=False) + source_id = Column(String(255), nullable=False) + title = Column(String(500), nullable=True) + author = Column(String(500), nullable=True) + cover_url = Column(String(500), nullable=True) + progress_percentage = Column(Float, default=0.0, nullable=False) + first_detected_at = Column(DateTime, default=utc_now) + last_seen_at = Column(DateTime, default=utc_now, onupdate=utc_now) + status = Column(String(20), default="detected") + matches_json = Column(Text, nullable=True) + device = Column(String(128), nullable=True) + ebook_filename = Column(String(500), nullable=True) + + def __init__( + self, + source_id: str, + title: str, + progress_percentage: float, + author: str = None, + cover_url: str = None, + status: str = "detected", + source: str = "abs", + matches_json: str = None, + device: str = None, + ebook_filename: str = None, + ): + self.source = source + self.source_id = source_id + self.title = title + self.author = author + self.cover_url = cover_url + self.progress_percentage = progress_percentage + self.status = status + self.matches_json = matches_json + self.device = device + self.ebook_filename = ebook_filename + self.first_detected_at = utc_now() + self.last_seen_at = utc_now() + + @property + def matches(self): + import json + + try: + return json.loads(self.matches_json) if self.matches_json else [] + except json.JSONDecodeError: + return [] + + def __repr__(self): + return ( + f"" + ) + + class Setting(Base): """ Setting model storing application configuration. diff --git a/src/services/kosync_service.py b/src/services/kosync_service.py index 10dafab..7514625 100644 --- a/src/services/kosync_service.py +++ b/src/services/kosync_service.py @@ -423,6 +423,7 @@ def run_put_auto_discovery(self, doc_hash): ) self._db.save_book(book, is_new=True) self._db.link_kosync_document(doc_hash, book.id, book.abs_id) + self._db.resolve_detected_book(doc_hash, source="kosync") self._db.resolve_suggestion(doc_hash) logger.info( f"Auto-created book '{match['title']}' from exact title match (abs_id={match['abs_id']})" @@ -519,6 +520,7 @@ def create_ebook_only_book(self, doc_hash, title, epub_filename=None): ) self._db.save_book(book, is_new=True) self._db.link_kosync_document(doc_hash, book.id, book.abs_id) + self._db.resolve_detected_book(doc_hash, source="kosync") self._db.resolve_suggestion(doc_hash) logger.info(f"Created ebook-only book: {book.id} '{title}'" + (f" -> {epub_filename}" if epub_filename else "")) @@ -621,6 +623,21 @@ def handle_put_progress(self, data, remote_addr, debounce_manager=None): self._db.save_kosync_document(kosync_doc) + if 0.01 <= percentage <= 0.70: + try: + suggestion_svc = self._container.suggestion_service() + suggestion_svc.queue_kosync_suggestion( + doc_hash, + filename=kosync_doc.filename, + device=device, + ) + detected = self._db.get_detected_book(doc_hash, source="kosync") + if detected: + detected.progress_percentage = float(percentage) + self._db.save_detected_book(detected) + except Exception as e: + logger.debug(f"KOSync detected-book update failed for {doc_hash[:8]}...: {e}") + # Update linked book if exists linked_book = None if kosync_doc.linked_book_id: diff --git a/src/services/suggestion_service.py b/src/services/suggestion_service.py index 0edec2e..31bd150 100644 --- a/src/services/suggestion_service.py +++ b/src/services/suggestion_service.py @@ -9,7 +9,7 @@ from difflib import SequenceMatcher from pathlib import Path -from src.db.models import PendingSuggestion +from src.db.models import DetectedBook, PendingSuggestion from src.utils.string_utils import clean_book_title logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ class SuggestionService: - """Handles suggestion discovery and creation for unmapped books.""" + """Handles detected-book discovery plus legacy suggestion workflows.""" def __init__( self, @@ -143,6 +143,32 @@ def _score_to_confidence(self, score: float) -> str: return "medium" return "low" + def _upsert_detected_book( + self, + *, + source: str, + source_id: str, + title: str, + progress_percentage: float, + author: str = "", + cover_url: str | None = None, + matches: list[dict] | None = None, + device: str | None = None, + ebook_filename: str | None = None, + ): + detected = DetectedBook( + source=source, + source_id=source_id, + title=title or source_id, + author=author or "", + cover_url=cover_url, + progress_percentage=max(0.0, min(progress_percentage, 1.0)), + matches_json=json.dumps(matches or []), + device=device, + ebook_filename=ebook_filename, + ) + return self.database_service.save_detected_book(detected) + def _get_bookfusion_context(self) -> dict: try: bf_books = list(self.database_service.get_bookfusion_books() or []) @@ -397,29 +423,21 @@ def _rank_candidates_for_book( return ranked[:6] def queue_suggestion(self, abs_id: str) -> None: - """Queue suggestion discovery for an unmapped book (called from socket listener).""" - if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": - return - + """Queue detected-book discovery for an unmapped ABS item.""" # Already mapped? all_books = self.database_service.get_all_books() mapped_ids = {b.abs_id for b in all_books} if abs_id in mapped_ids: return - if self._suggestion_already_recorded(abs_id): + if self._detected_book_is_dismissed(abs_id, source="abs"): return - logger.info(f"Socket.IO: Queuing suggestion discovery for '{abs_id[:12]}...'") + logger.info(f"Socket.IO: Queuing detected-book discovery for '{abs_id[:12]}...'") self._create_suggestion(abs_id, None) def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, device: str | None = None) -> None: - """Create a reverse suggestion for a KoSync document (ebook -> ABS audiobook).""" - if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": - return - if self.database_service.suggestion_exists(doc_hash, source="kosync"): - return - + """Create or refresh a detected entry for a KoSync document.""" title = "" if filename: title = Path(filename).stem @@ -431,7 +449,6 @@ def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, de logger.debug(f"KoSync suggestion: no title derivable for {doc_hash[:8]}..., skipping") return - # Try ABS audiobook matching first (if ABS is configured) matches = [] if self.abs_client: try: @@ -461,35 +478,36 @@ def queue_kosync_suggestion(self, doc_hash: str, filename: str | None = None, de all_ebook = ebook_candidates.get("storyteller", []) + ebook_candidates.get("grimmory", []) matches = self._rank_candidates_for_book(title, "", all_ebook) - if not matches: - logger.debug(f"KoSync suggestion: no match for '{title}' (hash {doc_hash[:8]}...)") - return + cover = None + author = "" + if matches: + best = matches[0] + author = best.get("author") or best.get("authorName") or "" + if best.get("abs_id"): + cover = f"/api/cover-proxy/{best['abs_id']}" + else: + cover = best.get("cover_url") or self._cover_url_for( + best.get("source_family", ""), best.get("abs_id", ""), best + ) - best = matches[0] - if best.get("abs_id"): - cover = f"/api/cover-proxy/{best['abs_id']}" - else: - cover = best.get("cover_url") or self._cover_url_for( - best.get("source_family", ""), best.get("abs_id", ""), best - ) - suggestion = PendingSuggestion( + self._upsert_detected_book( source="kosync", source_id=doc_hash, title=title, - author=best.get("author") or best.get("authorName") or "", + author=author, cover_url=cover, - matches_json=json.dumps(matches), + progress_percentage=0.0, + matches=matches, + device=device, + ebook_filename=filename, + ) + logger.info( + f"KoSync detected: '{title}' (hash {doc_hash[:8]}...)" + + (f" with {len(matches)} match(es)" if matches else "") ) - self.database_service.save_pending_suggestion(suggestion) - logger.info(f"KoSync suggestion: '{title}' -> '{best.get('title')}' (hash {doc_hash[:8]}...)") def check_for_suggestions(self, abs_progress_map, active_books): - """Check for unmapped books with progress and create suggestions.""" - suggestions_enabled_val = os.environ.get("SUGGESTIONS_ENABLED", "true") - logger.debug(f"SUGGESTIONS_ENABLED env var is: '{suggestions_enabled_val}'") - - if suggestions_enabled_val.lower() != "true": - return + """Check for unmapped books with progress and create detected entries.""" try: # optimization: get all mapped IDs to avoid suggesting existing books (even if inactive) @@ -497,7 +515,7 @@ def check_for_suggestions(self, abs_progress_map, active_books): mapped_ids = {b.abs_id for b in all_books} logger.debug( - f"Checking for suggestions: {len(abs_progress_map)} books with progress, {len(mapped_ids)} already mapped" + f"Checking for detected ABS books: {len(abs_progress_map)} books with progress, {len(mapped_ids)} already mapped" ) for abs_id, item_data in abs_progress_map.items(): @@ -511,8 +529,8 @@ def check_for_suggestions(self, abs_progress_map, active_books): if duration > 0: pct = current_time / duration if pct > 0.01: - if self._suggestion_already_recorded(abs_id): - logger.debug(f"Skipping {abs_id}: suggestion already exists/hidden") + if self._detected_book_is_dismissed(abs_id, source="abs"): + logger.debug(f"Skipping {abs_id}: detected entry dismissed") continue # Check if book is already mostly finished (>70%) @@ -521,14 +539,14 @@ def check_for_suggestions(self, abs_progress_map, active_books): logger.debug(f"Skipping {abs_id}: progress {pct:.1%} > 70% threshold") continue - logger.debug(f"Creating suggestion for {abs_id} (progress: {pct:.1%})") + logger.debug(f"Creating detected entry for {abs_id} (progress: {pct:.1%})") self._create_suggestion(abs_id, item_data) else: logger.debug(f"Skipping {abs_id}: progress {pct:.1%} below 1% threshold") else: logger.debug(f"Skipping {abs_id}: no duration") except Exception as e: - logger.error(f"Error checking suggestions: {e}") + logger.error(f"Error checking detected ABS books: {e}") # Reverse suggestions: ebook sources → ABS audiobooks try: @@ -542,9 +560,10 @@ def check_for_suggestions(self, abs_progress_map, active_books): except Exception as e: logger.warning(f"Cross-ebook suggestions check failed: {e}") - def _suggestion_already_recorded(self, abs_id: str) -> bool: - """Return True when a suggestion should not be recreated for this ABS item.""" - return bool(self.database_service.suggestion_exists(abs_id)) + def _detected_book_is_dismissed(self, source_id: str, source: str = "abs") -> bool: + """Return True when a dismissed detected entry should stay hidden.""" + detected = self.database_service.get_detected_book(source_id, source=source) + return bool(detected and detected.status == "dismissed") def _get_storyteller_books_with_progress(self, mapped_uuids: set | None = None) -> list[dict]: """Fetch Storyteller books with 1-70% progress, excluding already-mapped UUIDs.""" @@ -892,7 +911,7 @@ def _run_rescan_job(self) -> None: def rescan_library_suggestions(self) -> dict: """Rebuild suggestions from cached library metadata without live BookFusion calls.""" - if os.environ.get("SUGGESTIONS_ENABLED", "true").lower() != "true": + if os.environ.get("SUGGESTIONS_ENABLED", "false").lower() != "true": return {"created": 0, "updated": 0, "deleted": 0, "total": 0, "bookfusion_catalog": False} mapped_ids = {b.abs_id for b in self.database_service.get_all_books()} @@ -1045,17 +1064,17 @@ def _dedupe_matches(self, matches: list[dict], limit: int = 6) -> list[dict]: return sorted(deduped.values(), key=lambda m: m.get("score", 0.0), reverse=True)[:limit] def _create_suggestion(self, abs_id, progress_data): - """Create a new suggestion for an unmapped book.""" + """Create or update a detected ABS book for an unmapped item.""" with self._suggestion_lock: if abs_id in self._suggestion_in_flight: return self._suggestion_in_flight.add(abs_id) try: - logger.info(f"Found potential new book for suggestion: '{abs_id}'") + logger.info(f"Found potential new detected ABS book: '{abs_id}'") item = self.abs_client.get_item_details(abs_id) if not item: - logger.debug(f"Suggestion failed: Could not get details for {abs_id}") + logger.debug(f"Detected book lookup failed: Could not get details for {abs_id}") return media = item.get("media", {}) @@ -1063,7 +1082,14 @@ def _create_suggestion(self, abs_id, progress_data): title = metadata.get("title") or "" author = metadata.get("authorName") or "" cover = self._cover_url_for("abs", abs_id) - logger.debug(f"Checking suggestions for '{title}' (Author: {author})") + logger.debug(f"Checking detected matches for '{title}' (Author: {author})") + + progress_percentage = 0.0 + if progress_data: + duration = progress_data.get("duration", 0) or 0 + current_time = progress_data.get("currentTime", 0) or 0 + if duration > 0: + progress_percentage = max(0.0, min(current_time / duration, 1.0)) bookfusion_context = self._get_bookfusion_context() matches = self._rank_candidates_for_book( @@ -1075,18 +1101,24 @@ def _create_suggestion(self, abs_id, progress_data): matches.extend(self._search_live_candidates(title, author, bookfusion_context)) matches = self._dedupe_matches(matches) - if not matches: - logger.debug(f"No matches found for '{title}', skipping suggestion creation") - return + self._upsert_detected_book( + source="abs", + source_id=abs_id, + title=title, + author=author, + cover_url=cover, + progress_percentage=progress_percentage, + matches=matches, + ) - suggestion = PendingSuggestion( - source_id=abs_id, title=title, author=author, cover_url=cover, matches_json=json.dumps(matches) + logger.info( + f"Created detected entry for '{title}' with {len(matches)} matches" + if matches + else f"Created detected entry for '{title}' with no matches yet" ) - self.database_service.save_pending_suggestion(suggestion) - logger.info(f"Created suggestion for '{title}' with {len(matches)} matches") except Exception as e: - logger.error(f"Failed to create suggestion for '{abs_id}': {e}") + logger.error(f"Failed to create detected entry for '{abs_id}': {e}") logger.debug(traceback.format_exc()) finally: with self._suggestion_lock: diff --git a/src/sync_manager.py b/src/sync_manager.py index 7cb72cf..4bfcc44 100644 --- a/src/sync_manager.py +++ b/src/sync_manager.py @@ -155,13 +155,14 @@ def _setup_sync_clients(self, clients: dict[str, SyncClient]): def startup_checks(self): # Check configured sync clients for client_name, client in (self.sync_clients or {}).items(): + first_err = RuntimeError("unknown startup check failure") try: if client.check_connection(): logger.info(f"'{client_name}' connection verified") continue first_err = RuntimeError("check_connection() returned False") - except Exception as first_err: - pass + except Exception as e: + first_err = e time.sleep(2) try: @@ -238,13 +239,8 @@ def cleanup_cache(self): if book.ebook_filename: valid_filenames.add(book.ebook_filename) - # From Pending Suggestions (covers auto-discovery matches) - suggestions = self.database_service.get_all_actionable_suggestions() - for suggestion in suggestions: - # matches property automatically parses the JSON - for match in suggestion.matches: - if match.get("filename"): - valid_filenames.add(match["filename"]) + # From Detected Books (covers auto-discovery matches) + valid_filenames.update(self.database_service.get_all_ebook_filenames()) # 2. Iterate cache and delete orphans deleted_count = 0 diff --git a/static/css/kosync.css b/static/css/kosync.css index 082e41e..fe63bfc 100644 --- a/static/css/kosync.css +++ b/static/css/kosync.css @@ -317,6 +317,107 @@ flex-shrink: 0; } +/* ── Dashboard: Detected ── */ +.detected-help { + font-size: 13px; + color: var(--color-text-muted); + margin: 0 0 12px; +} + +.detected-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} + +.detected-card { + display: flex; + gap: 12px; + padding: 14px; + background: var(--color-surface-2); + border-radius: 8px; + border-left: 3px solid var(--color-primary); +} + +.detected-cover { + width: 60px; + height: 90px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.detected-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.detected-title { + font-weight: 600; + font-size: 14px; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.detected-author { + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.detected-progress { + font-size: 12px; + font-weight: 600; + color: var(--color-primary); +} + +.detected-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 2px; +} + +.detected-device { + font-size: 11px; + color: var(--color-text-muted); +} + +.detected-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.chip--source-abs { + background: #3b82f6; + color: #fff; +} + +.chip--source-kosync { + background: #8b5cf6; + color: #fff; +} + +.chip--source-storyteller { + background: #10b981; + color: #fff; +} + +.chip--source-grimmory { + background: #f59e0b; + color: #fff; +} + /* ── Mobile responsive ── */ @media (max-width: 600px) { .kosync-card { diff --git a/static/css/suggestions.css b/static/css/suggestions.css deleted file mode 100644 index dba8881..0000000 --- a/static/css/suggestions.css +++ /dev/null @@ -1,497 +0,0 @@ -/* PageKeeper - Suggestions page styles */ - -.suggestions-shell { - margin-bottom: 60px; -} - -.sg-page-header { - display: flex; - justify-content: space-between; - align-items: flex-end; - gap: 24px; - margin-bottom: 24px; -} - -.sg-header-copy { - max-width: 760px; -} - -.sg-page-kicker { - margin: 0 0 6px; - color: var(--color-text-faint); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.sg-page-title { - margin: 0 0 10px; - font-family: var(--font-heading); - font-size: 30px; - font-weight: 700; - color: var(--color-text); -} - -.sg-page-description { - margin: 0; - color: var(--color-text-muted); - max-width: 720px; -} - -.sg-toolbar { - position: sticky; - top: 0; - z-index: 40; - display: flex; - flex-direction: column; - gap: 10px; - margin-bottom: 20px; - padding: 14px 16px; - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-light); - background: - linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(33, 30, 49, 0.96) 35%, rgba(33, 30, 49, 0.96) 100%); - -webkit-backdrop-filter: blur(12px); - backdrop-filter: blur(12px); -} - -.sg-toolbar-main { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.sg-toolbar .search-box { - width: 320px; - max-width: 100%; -} - -.sg-toolbar select { - min-width: 180px; - padding: 9px 12px; - border: 1px solid var(--color-border-light); - border-radius: var(--radius); - background: var(--color-surface); - color: var(--color-text); -} - -.sg-toolbar-status { - min-height: 18px; -} - -.sg-view-toggle { - display: flex; - background: var(--color-bg-input); - border: 1px solid var(--color-border); - border-radius: var(--radius); - overflow: hidden; - margin-left: auto; -} - -.sg-view-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 7px 10px; - background: transparent; - border: none; - color: var(--color-text-faint); - cursor: pointer; - transition: all var(--transition); -} - -.sg-view-btn:hover:not(:disabled) { - color: var(--color-text); -} - -.sg-view-btn.active { - background: var(--color-surface); - color: var(--color-text); -} - -.sg-view-btn:disabled { - opacity: 0.45; - cursor: default; -} - -.stats-strip { - display: flex; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 22px; -} - -.stat-pill { - border: 1px solid var(--color-border-light); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(8, 11, 20, 0.18)); - padding: 10px 14px; - border-radius: 999px; - font-size: 0.9rem; - color: var(--color-text-muted); -} - -.stat-pill strong { - color: var(--color-text); -} - -.suggestions-results { - margin-bottom: 24px; -} - -.suggestion-grid { - display: grid; - gap: 18px; -} - -.suggestions-results.sg-grid-view .suggestion-grid, -.suggestion-grid:not(.sg-list-grid) { - grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); -} - -.suggestion-grid.sg-list-grid, -.suggestions-results.sg-list-view .suggestion-grid { - grid-template-columns: 1fr; -} - -.suggestion-card { - display: flex; - flex-direction: column; - gap: 16px; - min-width: 0; - background: - radial-gradient(circle at top right, rgba(59, 130, 246, 0.08), transparent 30%), - linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(8, 11, 20, 0.2)); - border: 1px solid var(--color-border-light); - border-radius: 22px; - padding: 20px; - transition: transform var(--transition-bounce), border-color var(--transition), box-shadow var(--transition); -} - -.suggestion-card:hover { - transform: translateY(-1px); - border-color: rgba(255, 255, 255, 0.08); - box-shadow: 0 18px 36px rgba(8, 11, 20, 0.18); -} - -.suggestion-card[data-has-bookfusion="true"] { - border-color: rgba(59, 130, 246, 0.35); - box-shadow: 0 12px 32px rgba(59, 130, 246, 0.08); -} - -.suggestion-card--hidden { - opacity: 0.68; -} - -.suggestion-card--hidden:hover { - opacity: 1; -} - -.suggestions-results.sg-list-view .suggestion-card, -.suggestion-grid.sg-list-grid .suggestion-card { - display: grid; - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr) auto; - align-items: start; - gap: 18px; -} - -.suggestion-source { - display: flex; - gap: 14px; - min-width: 0; -} - -.suggestion-cover { - width: 72px; - height: 108px; - border-radius: 12px; - object-fit: cover; - flex-shrink: 0; - background: var(--color-bg); -} - -.suggestion-meta { - min-width: 0; -} - -.suggestion-meta h3 { - margin: 0 0 4px; - font-size: 1.05rem; - color: var(--color-text); - overflow-wrap: anywhere; -} - -.suggestion-meta p { - margin: 0; - color: var(--color-text-muted); - font-size: 0.9rem; -} - -.badge-row { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 600; - background: var(--color-bg); - color: var(--color-text-muted); -} - -.chip--bookfusion { - background: rgba(59, 130, 246, 0.12); - color: var(--color-bookfusion); -} - -.chip--confidence-high { - background: var(--color-confidence-high-bg); - color: var(--color-confidence-high); -} - -.chip--confidence-medium { - background: var(--color-confidence-medium-bg); - color: var(--color-confidence-medium); -} - -.chip--source-kosync { - background: rgba(167, 139, 250, 0.14); - color: var(--color-kosync); -} - -.candidate-list { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.candidate { - border: 1px solid var(--color-border-light); - border-radius: 16px; - padding: 12px; - background: rgba(255, 255, 255, 0.03); -} - -.candidate-top { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; - margin-bottom: 8px; -} - -.candidate-title { - font-weight: 600; - margin-bottom: 2px; - color: var(--color-text); -} - -.candidate-author { - color: var(--color-text-muted); - font-size: 0.84rem; -} - -.candidate-score { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--color-text-muted); - font-size: 0.8rem; - white-space: nowrap; -} - -.candidate-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; -} - -.suggestion-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-content: flex-start; -} - -.suggestions-results.sg-grid-view .suggestion-actions, -.suggestion-grid:not(.sg-list-grid) .suggestion-actions { - margin-top: 2px; - padding-top: 14px; - border-top: 1px solid var(--color-border-light); -} - -.suggestions-results.sg-list-view .suggestion-actions, -.suggestion-grid.sg-list-grid .suggestion-actions { - flex-direction: column; - min-width: 132px; -} - -.suggestions-results.sg-list-view .suggestion-actions .btn, -.suggestion-grid.sg-list-grid .suggestion-actions .btn { - width: 100%; -} - -.empty-state { - text-align: center; - padding: 56px 18px; - color: var(--color-text-muted); - border: 1px dashed var(--color-border-light); - border-radius: 18px; - background: var(--color-surface); -} - -.help-note { - color: var(--color-text-muted); - font-size: 0.85rem; -} - -.suggestions-hidden-section { - margin-top: 24px; - border-top: 1px solid var(--color-border-light); - padding-top: 16px; -} - -.suggestions-hidden-header { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 16px; - background: var(--color-surface); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-lg); - cursor: pointer; - font-weight: 600; - font-family: var(--font-heading); - font-size: 0.9rem; - color: var(--color-text-muted); - user-select: none; - transition: all var(--transition); -} - -.suggestions-hidden-header:hover { - border-color: var(--color-border); - background: var(--color-surface-hover); -} - -.suggestions-hidden-header:focus-visible { - outline: 2px solid var(--color-primary-hover); - outline-offset: 2px; -} - -.suggestions-hidden-header .chevron { - transition: transform 0.2s; - font-size: 0.8rem; -} - -.suggestions-hidden-count { - color: var(--color-text-muted); -} - -.suggestions-hidden-header.collapsed .chevron { - transform: rotate(-90deg); -} - -.suggestions-hidden-body { - margin-top: 10px; -} - -.suggestions-hidden-empty { - margin-top: 8px; -} - -@media (max-width: 1120px) { - .suggestions-results.sg-list-view .suggestion-card, - .suggestion-grid.sg-list-grid .suggestion-card { - grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); - } - - .suggestions-results.sg-list-view .suggestion-actions, - .suggestion-grid.sg-list-grid .suggestion-actions { - grid-column: 1 / -1; - flex-direction: row; - min-width: 0; - padding-top: 4px; - border-top: 1px solid var(--color-border-light); - } - - .suggestions-results.sg-list-view .suggestion-actions .btn, - .suggestion-grid.sg-list-grid .suggestion-actions .btn { - width: auto; - } -} - -@media (max-width: 960px) { - .sg-page-header { - flex-direction: column; - align-items: stretch; - gap: 14px; - } - - .sg-toolbar-main { - align-items: stretch; - } - - .sg-toolbar .search-box, - .sg-toolbar select { - width: 100%; - } - - .sg-view-toggle { - margin-left: 0; - align-self: flex-start; - } -} - -@media (max-width: 768px) { - .sg-page-header { - gap: 12px; - margin-bottom: 18px; - } - - .sg-page-kicker { - margin-bottom: 4px; - font-size: 10px; - letter-spacing: 0.1em; - } - - .sg-page-title { - font-size: 26px; - margin-bottom: 8px; - } - - .sg-toolbar { - padding: 12px; - } - - .suggestion-grid, - .suggestions-results.sg-grid-view .suggestion-grid { - grid-template-columns: 1fr; - } - - .suggestions-results.sg-list-view .suggestion-card, - .suggestion-grid.sg-list-grid .suggestion-card { - grid-template-columns: 1fr; - } - - .candidate-top { - flex-direction: column; - } - - .suggestion-actions { - flex-direction: row; - } - - .suggestion-actions .btn { - width: auto; - } -} diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 1d8f00f..81deb7b 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -853,35 +853,21 @@ document.addEventListener('keydown', function(e) { } }); -/* Suggestion banner dismiss */ -function dismissSuggestion(sourceId, source, btn) { +/* Detected card dismiss */ +function dismissDetected(sourceId, source, btn) { if (btn) btn.disabled = true; - fetch('/api/suggestions/' + encodeURIComponent(sourceId) + '/hide?source=' + encodeURIComponent(source || 'abs'), { method: 'POST' }) + fetch('/api/detected/' + encodeURIComponent(sourceId) + '/dismiss?source=' + encodeURIComponent(source || 'abs'), { method: 'POST' }) .then(function(r) { if (!r.ok) throw new Error('Failed to dismiss'); - var card = document.getElementById('suggestion-card-' + sourceId); + var card = document.querySelector('.detected-card[data-source-id="' + sourceId + '"]'); if (card) { card.classList.add('removing'); setTimeout(function() { card.remove(); - var banner = document.getElementById('suggestion-banner'); - var remaining = banner ? banner.querySelectorAll('.suggestion-banner-card') : []; - if (remaining.length === 0 && banner) { - banner.style.display = 'none'; - } - var countEl = document.getElementById('suggestion-banner-count'); - if (countEl && remaining.length > 0) { - countEl.textContent = remaining.length; - } - // Update navbar badge - var badge = document.querySelector('.nav-badge'); - if (badge) { - var current = parseInt(badge.textContent, 10) || 0; - if (current > 1) { - badge.textContent = current - 1; - } else { - badge.style.display = 'none'; - } + var section = document.getElementById('detected-section'); + var remaining = section ? section.querySelectorAll('.detected-card') : []; + if (remaining.length === 0 && section) { + section.style.display = 'none'; } }, 200); } diff --git a/static/js/settings.js b/static/js/settings.js index e153a09..994bf6c 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -265,33 +265,6 @@ function copyInputValue(inputId) { /* ─── Tool Actions ─── */ -function clearStaleSuggestions() { - PKModal.confirm({ - title: 'Clear Stale Suggestions', - message: 'This will permanently delete all suggestions for books that are not currently matched in your bridge. Books you are already syncing will be preserved.', - confirmLabel: 'Clear', - confirmClass: 'btn btn-danger', - onConfirm: function() { - fetch('/api/suggestions/clear_stale', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.success) { - PKModal.alert({ title: 'Success', message: 'Cleared ' + data.count + ' stale suggestions.' }); - } else { - PKModal.alert({ title: 'Error', message: 'Failed to clear suggestions: ' + (data.error || 'Unknown error') }); - } - }) - .catch(function(err) { - console.error('Error clearing suggestions:', err); - PKModal.alert({ title: 'Error', message: 'An error occurred while clearing suggestions.' }); - }); - } - }); -} - function syncReadingDates(btn) { btn.disabled = true; btn.textContent = 'Syncing\u2026'; diff --git a/static/js/suggestions.js b/static/js/suggestions.js deleted file mode 100644 index 9940dc3..0000000 --- a/static/js/suggestions.js +++ /dev/null @@ -1,447 +0,0 @@ -/* ═══════════════════════════════════════════ - PageKeeper — suggestions page - ═══════════════════════════════════════════ - Depends on: utils.js, confirm-modal.js - Reads: window.PK_PAGE_DATA.suggestionsData - window.PK_PAGE_DATA.selectedSourceId - ═══════════════════════════════════════════ */ - -(function () { - 'use strict'; - - var suggestionData = window.PK_PAGE_DATA.suggestionsData; - var selectedSourceId = window.PK_PAGE_DATA.selectedSourceId; - var rescanPollTimer = null; - var desktopMedia = window.matchMedia('(min-width: 961px)'); - var currentView = 'list'; - - /* ── helpers ── */ - - function formatEvidence(evidence) { - return (evidence || []).map(function (item) { - return '' + escapeHtml(item.split('_').join(' ')) + ''; - }).join(''); - } - - function confidenceRank(confidence) { - if (confidence === 'high') return 3; - if (confidence === 'medium') return 2; - return 1; - } - - function filterSuggestions() { - var query = (document.getElementById('suggestion-search').value || '').toLowerCase().trim(); - var confidenceFilter = document.getElementById('confidence-filter').value; - var bfFilterEl = document.getElementById('bookfusion-filter'); - var bookfusionFilter = bfFilterEl ? bfFilterEl.value : 'all'; - - return suggestionData.filter(function (suggestion) { - if (selectedSourceId && suggestion.source_id !== selectedSourceId) return false; - if (bookfusionFilter === 'bookfusion' && !suggestion.has_bookfusion_evidence) return false; - - var topConfidence = suggestion.top_match ? suggestion.top_match.confidence : 'low'; - if (confidenceFilter === 'high' && topConfidence !== 'high') return false; - if (confidenceFilter === 'medium' && confidenceRank(topConfidence) < 2) return false; - - if (!query) return true; - var haystack = [ - suggestion.title, - suggestion.author - ].concat((suggestion.matches || []).map(function (match) { - return [match.title, match.author, match.filename, match.source_family, (match.evidence || []).join(' ')].join(' '); - })).join(' ').toLowerCase(); - return haystack.indexOf(query) !== -1; - }); - } - - /* ── rendering ── - Note: all user-facing strings are passed through escapeHtml() (from utils.js) - before insertion into HTML markup strings. */ - - function renderCandidate(match, suggestion, index) { - var confidenceClass = 'chip--confidence-' + (match.confidence || 'low'); - var actions = []; - var sgSource = suggestion.source || 'unknown'; - - if (!suggestion.hidden) { - if (match.source_family === 'bookfusion') { - actions.push(''); - if (match.bookfusion_ids && match.bookfusion_ids.length) { - actions.push(''); - } - } else if (match.action_kind === 'create_ebook_mapping') { - var ebookMappingUrl = '/match?search=' + encodeURIComponent(match.title || suggestion.title || ''); - actions.push('Create Ebook Mapping'); - } else { - var mappingUrl = '/match?search=' + encodeURIComponent(suggestion.title || ''); - if (sgSource === 'abs') { - mappingUrl = '/match?abs_id=' + encodeURIComponent(suggestion.source_id) + '&search=' + encodeURIComponent(suggestion.title || ''); - } else if (match.abs_id) { - mappingUrl = '/match?abs_id=' + encodeURIComponent(match.abs_id) + '&search=' + encodeURIComponent(match.title || suggestion.title || ''); - } - actions.push('Create Mapping'); - } - } - - return '' + - '
' + - '
' + - '
' + - '
' + escapeHtml(match.title || match.filename || 'Untitled') + '
' + - '
' + escapeHtml(match.author || match.source_family || '') + '
' + - '
' + - '
' + - '' + escapeHtml(match.confidence || 'low') + '' + - '' + Math.round((match.score || 0) * 100) + '%' + - '
' + - '
' + - '
' + - '' + escapeHtml(match.source_family || match.source || 'unknown') + '' + - formatEvidence(match.evidence) + - '
' + - (match.highlight_count ? '
BookFusion highlights: ' + escapeHtml(match.highlight_count) + '
' : '') + - (actions.length ? '
' + actions.join('') + '
' : '') + - '
'; - } - - function renderSuggestionCard(suggestion) { - var matches = (suggestion.matches || []).map(function (match, index) { - return renderCandidate(match, suggestion, index); - }).join(''); - - var suggestionSource = suggestion.source || 'unknown'; - var actionButtons = suggestion.hidden - ? '' - : ''; - - return '' + - '
' + - '
' + - (suggestion.cover_url - ? '' - : '
') + - '
' + - '

' + escapeHtml(suggestion.title) + '

' + - '

' + escapeHtml(suggestion.author || 'Unknown author') + '

' + - '
' + - (suggestion.source && suggestion.source !== 'abs' ? '' + escapeHtml(suggestion.source) + '' : '') + - '' + escapeHtml((suggestion.matches || []).length) + ' candidates' + - (suggestion.hidden ? 'Hidden' : '') + - (suggestion.has_bookfusion_evidence ? 'BookFusion evidence' : '') + - '
' + - '
' + - '
' + - '
' + matches + '
' + - '
' + - actionButtons + - '' + - '
' + - '
'; - } - - /* ── view toggle ── */ - - function setView(view, persist) { - var results = document.getElementById('suggestions-results'); - var hiddenGrid = document.getElementById('hidden-grid'); - var viewButtons = document.querySelectorAll('.sg-view-btn'); - var forcedView = desktopMedia.matches ? view : 'list'; - - currentView = forcedView; - - if (results) { - results.classList.toggle('sg-grid-view', forcedView === 'grid'); - results.classList.toggle('sg-list-view', forcedView !== 'grid'); - } - - if (hiddenGrid) { - hiddenGrid.classList.toggle('sg-list-grid', forcedView !== 'grid'); - } - - viewButtons.forEach(function (btn) { - var isActive = btn.dataset.view === forcedView; - btn.classList.toggle('active', isActive); - btn.disabled = !desktopMedia.matches; - btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); - }); - - if (persist && desktopMedia.matches) { - try { localStorage.setItem('pk-suggestions-view', forcedView); } catch (e) {} - } - } - - /* ── main render ── */ - - function renderSuggestions() { - var filtered = filterSuggestions(); - var visible = filtered.filter(function (item) { return !item.hidden; }); - var hidden = filtered.filter(function (item) { return item.hidden; }); - var grid = document.getElementById('suggestion-grid'); - var hiddenSection = document.getElementById('hidden-section'); - var hiddenGrid = document.getElementById('hidden-grid'); - var hiddenCount = document.getElementById('hidden-section-count'); - var empty = document.getElementById('empty-state'); - - document.getElementById('visible-count').textContent = visible.length; - document.getElementById('hidden-count').textContent = hidden.length; - document.getElementById('total-count').textContent = filtered.length; - - /* All values passed to renderSuggestionCard are escapeHtml-sanitized */ - grid.innerHTML = visible.map(renderSuggestionCard).join(''); // eslint-disable-line no-unsanitized/property - - if (hidden.length) { - hiddenSection.classList.remove('hidden'); - hiddenGrid.innerHTML = hidden.map(renderSuggestionCard).join(''); // eslint-disable-line no-unsanitized/property - hiddenCount.textContent = '(' + hidden.length + ')'; - } else { - hiddenSection.classList.add('hidden'); - hiddenGrid.textContent = ''; - hiddenCount.textContent = '(0)'; - } - - if (!visible.length) { - empty.classList.remove('hidden'); - } else { - empty.classList.add('hidden'); - } - } - - /* ── state management ── */ - - function updateSuggestionState(sourceId, updater) { - suggestionData = suggestionData.map(function (item) { - if (item.source_id !== sourceId) return item; - return updater(Object.assign({}, item)); - }).filter(Boolean); - renderSuggestions(); - } - - function showErrorToast(message) { - PKModal.alert({ title: 'Error', message: message }); - } - - function actOnSuggestion(url, btn, onSuccess) { - if (btn) btn.disabled = true; - fetch(url, { method: 'POST' }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Request failed'); - onSuccess(); - }) - .catch(function (err) { - if (btn) btn.disabled = false; - showErrorToast(err.message || String(err)); - }); - } - - /* ── actions ── */ - - function hideSuggestion(sourceId, source, btn) { - PKModal.confirm({ - title: 'Hide Suggestion?', - message: 'This suggestion will move to the Hidden section. You can restore it later.', - confirmLabel: 'Hide', - confirmClass: 'btn', - onConfirm: function () { - actOnSuggestion('/api/suggestions/' + encodeURIComponent(sourceId) + '/hide?source=' + encodeURIComponent(source || 'abs'), btn, function () { - updateSuggestionState(sourceId, function (item) { - item.status = 'hidden'; - item.hidden = true; - return item; - }); - }); - } - }); - } - - function ignoreSuggestion(sourceId, source, btn) { - PKModal.confirm({ - title: 'Never Ask Again?', - message: 'This suggestion will be permanently ignored and will not return on future rescans.', - confirmLabel: 'Never Ask', - confirmClass: 'btn btn-danger', - onConfirm: function () { - actOnSuggestion('/api/suggestions/' + encodeURIComponent(sourceId) + '/ignore?source=' + encodeURIComponent(source || 'abs'), btn, function () { - updateSuggestionState(sourceId, function () { - return null; - }); - }); - } - }); - } - - function unhideSuggestion(sourceId, source, btn) { - actOnSuggestion('/api/suggestions/' + encodeURIComponent(sourceId) + '/unhide?source=' + encodeURIComponent(source || 'abs'), btn, function () { - updateSuggestionState(sourceId, function (item) { - item.status = 'pending'; - item.hidden = false; - return item; - }); - }); - } - - function linkBookFusion(sourceId, matchIndex, source) { - fetch('/api/suggestions/' + encodeURIComponent(sourceId) + '/link-bookfusion', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ match_index: matchIndex, source: source || 'abs' }) - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Link failed'); - refreshSuggestionsData('BookFusion link created.'); - }) - .catch(function (err) { - showErrorToast(err.message || String(err)); - }); - } - - function addBookFusionToDashboard(bookfusionIds) { - fetch('/api/bookfusion/add-to-dashboard', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bookfusion_ids: bookfusionIds }) - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Add failed'); - window.location.href = '/'; - }) - .catch(function (err) { - showErrorToast(err.message || String(err)); - }); - } - - /* ── rescan ── */ - - function rescanSuggestions() { - var btn = document.getElementById('rescan-btn'); - var status = document.getElementById('rescan-status'); - btn.disabled = true; - status.textContent = 'Queued library rescan...'; - fetch('/api/suggestions/rescan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) - }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Rescan failed'); - if (data.rate_limited) { - status.textContent = data.message || ('Please wait ' + (data.next_allowed_in || 0) + 's before rescanning again.'); - btn.disabled = false; - return; - } - status.textContent = data.message || 'Suggestions rescan started...'; - pollRescanStatus(); - }) - .catch(function (err) { - status.textContent = err.message || String(err); - btn.disabled = false; - }); - } - - function pollRescanStatus() { - if (rescanPollTimer) { - clearTimeout(rescanPollTimer); - rescanPollTimer = null; - } - fetch('/api/suggestions/rescan-status') - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Status failed'); - var status = document.getElementById('rescan-status'); - var btn = document.getElementById('rescan-btn'); - - if (data.running) { - status.textContent = data.message || 'Rescan in progress...'; - btn.disabled = true; - rescanPollTimer = setTimeout(pollRescanStatus, 1500); - return; - } - - if (data.phase === 'complete') { - refreshSuggestionsData(data.message || 'Rescan complete.'); - return; - } - - if (data.rate_limited) { - status.textContent = data.message || ('Please wait ' + (data.next_allowed_in || 0) + 's before rescanning again.'); - } else if (data.message) { - status.textContent = data.message; - } - btn.disabled = false; - }) - .catch(function (err) { - document.getElementById('rescan-status').textContent = err.message || String(err); - document.getElementById('rescan-btn').disabled = false; - }); - } - - function refreshSuggestionsData(statusMessage) { - fetch('/api/suggestions') - .then(function (r) { return r.json(); }) - .then(function (data) { - suggestionData = data; - renderSuggestions(); - var status = document.getElementById('rescan-status'); - var btn = document.getElementById('rescan-btn'); - if (statusMessage) status.textContent = statusMessage; - btn.disabled = false; - }) - .catch(function (err) { - var status = document.getElementById('rescan-status'); - var btn = document.getElementById('rescan-btn'); - status.textContent = 'Refresh failed: ' + (err.message || String(err)); - btn.disabled = false; - }); - } - - /* ── init ── */ - - document.querySelectorAll('.sg-view-btn').forEach(function (btn) { - btn.addEventListener('click', function () { - setView(btn.dataset.view, true); - }); - }); - - try { - var savedView = localStorage.getItem('pk-suggestions-view'); - setView(savedView === 'grid' ? 'grid' : 'list', false); - } catch (e) { - setView('list', false); - } - - function handleViewportChange() { - var savedView = 'list'; - try { savedView = localStorage.getItem('pk-suggestions-view') || 'list'; } catch (e) {} - setView(savedView, false); - } - - if (desktopMedia.addEventListener) { - desktopMedia.addEventListener('change', handleViewportChange); - } else if (desktopMedia.addListener) { - desktopMedia.addListener(handleViewportChange); - } - - /* Wire up filter inputs */ - document.getElementById('suggestion-search').addEventListener('input', renderSuggestions); - document.getElementById('confidence-filter').addEventListener('change', renderSuggestions); - var bfFilter = document.getElementById('bookfusion-filter'); - if (bfFilter) bfFilter.addEventListener('change', renderSuggestions); - - /* Wire up rescan button */ - document.getElementById('rescan-btn').addEventListener('click', rescanSuggestions); - - renderSuggestions(); - pollRescanStatus(); - - /* ── expose functions called from inline onclick in rendered HTML ── */ - window.PK_Suggestions = { - hideSuggestion: hideSuggestion, - unhideSuggestion: unhideSuggestion, - ignoreSuggestion: ignoreSuggestion, - linkBookFusion: linkBookFusion, - addBookFusionToDashboard: addBookFusionToDashboard - }; -})(); diff --git a/templates/index.html b/templates/index.html index d266ff5..c81530a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -101,35 +101,38 @@

Pending Identification

{% endif %} - {% if top_suggestions %} -
+ {% if detected_books %} +
-

High-confidence matches found for books you're listening to.

-
- {% for sg in top_suggestions %} -
- -
-
{{ sg.title }}
- {% if sg.author %}
{{ sg.author }}
{% endif %} - {% if sg.top_match %} -
- {{ sg.top_match.title }} - {{ sg.top_match.confidence }} -
+

Books PageKeeper has detected you started. Add them to your library.

+
+ {% for d in detected_books %} +
+ {% if d.cover_url %} + + {% endif %} +
+
{{ d.title }}
+ {% if d.author %}
{{ d.author }}
{% endif %} + {% if d.progress_percentage > 0 %} +
{{ '%.0f'|format(d.progress_percentage * 100) }}%
{% endif %} -
- {% if sg.source == 'abs' %} - Map Now - {% elif sg.top_match and sg.top_match.abs_id %} - Map Now +
+ {{ d.source }} + {% if d.device %}{{ d.device }}{% endif %} +
+
+ {% if d.source == 'abs' %} + Add to Library + {% elif d.source == 'kosync' %} + Match + Add as Ebook {% else %} - Review + Add to Library {% endif %} - +
diff --git a/templates/partials/navbar.html b/templates/partials/navbar.html index b6dc7dd..a2bed79 100644 --- a/templates/partials/navbar.html +++ b/templates/partials/navbar.html @@ -82,9 +82,6 @@

PageKeeper

{% endif %} Dashboard Reading Log - {% if get_bool('SUGGESTIONS_ENABLED') %} - Suggestions{% if suggestion_count %} {{ suggestion_count }}{% endif %} - {% endif %} Batch Logs Settings diff --git a/templates/settings.html b/templates/settings.html index 952b4a5..79383e6 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -723,49 +723,6 @@

Tools

Optional features and maintenance utilities.

- -
-
-

Suggestions

-
- Enable - -
-
-
-
-
- When enabled, the system will look for unmapped audiobooks with progress and suggest - matching ebooks from your library. -
-
-
-
- - -
-
-

Remove Stale Suggestions

-
-
-
-
- -

- Deletes suggestions for Audiobookshelf audiobooks that are no longer in your library. - Useful after removing books from ABS. -

-
-
-
-
-
diff --git a/templates/suggestions.html b/templates/suggestions.html deleted file mode 100644 index 5d33000..0000000 --- a/templates/suggestions.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - {{ title_prefix }}Suggestions - PageKeeper - - - - - - - - - - - - - {% include 'partials/navbar.html' %} - -
-
-
-

Discovery Queue

-

Pairing Suggestions

-

Review potential audiobook & ebook pairings

-
-
- -
-
- -
-
- - - {% if bookfusion_enabled %} - - {% endif %} -
- - -
-
- -
- -
-
{{ visible_count }} visible suggestions
-
{{ hidden_count }} hidden suggestions
-
{{ suggestions|length }} total actionable
- {% if bookfusion_enabled %} -
{{ bookfusion_catalog_count }} BookFusion catalog items cached
- {% endif %} -
{{ 'enabled' if suggestions_enabled else 'disabled' }} suggestions status
-
- -
-
-
- - - - - - {% include 'partials/confirm_modal.html' %} -
- - - - - - - - diff --git a/tests/test_database_service_integration.py b/tests/test_database_service_integration.py index c109137..aa2b9a3 100644 --- a/tests/test_database_service_integration.py +++ b/tests/test_database_service_integration.py @@ -79,10 +79,51 @@ def test_create_book(self): self.assertEqual(saved_book.title, "Test Book Creation") self.assertEqual(saved_book.status, "active") - # Verify book can be retrieved - retrieved_book = self.db_service.get_book_by_abs_id(test_abs_id) - self.assertIsNotNone(retrieved_book) - self.assertEqual(retrieved_book.abs_id, test_abs_id) + def test_save_detected_book_creates_and_updates(self): + """Detected books upsert by source and source_id.""" + from src.db.models import DetectedBook + + first = DetectedBook( + source="abs", + source_id="detected-1", + title="Detected Title", + author="Author One", + progress_percentage=0.25, + cover_url="/cover/1", + ) + saved = self.db_service.save_detected_book(first) + self.assertEqual(saved.title, "Detected Title") + self.assertAlmostEqual(saved.progress_percentage, 0.25) + + second = DetectedBook( + source="abs", + source_id="detected-1", + title="Detected Title Updated", + author="Author Two", + progress_percentage=0.55, + ) + updated = self.db_service.save_detected_book(second) + + self.assertEqual(updated.id, saved.id) + self.assertEqual(updated.title, "Detected Title Updated") + self.assertEqual(updated.author, "Author Two") + self.assertAlmostEqual(updated.progress_percentage, 0.55) + + def test_resolve_detected_book_scoped_by_source(self): + """Resolving a detected book only affects the matching source row.""" + from src.db.models import DetectedBook + + abs_detected = DetectedBook(source="abs", source_id="shared-id", title="ABS", progress_percentage=0.2) + kosync_detected = DetectedBook(source="kosync", source_id="shared-id", title="KOSync", progress_percentage=0.3) + self.db_service.save_detected_book(abs_detected) + self.db_service.save_detected_book(kosync_detected) + + self.assertTrue(self.db_service.resolve_detected_book("shared-id", source="abs")) + + resolved = self.db_service.get_detected_book("shared-id", source="abs") + still_active = self.db_service.get_detected_book("shared-id", source="kosync") + self.assertEqual(resolved.status, "resolved") + self.assertEqual(still_active.status, "detected") def test_delete_book(self): """Test deleting a book record with cascading deletes for states and hardcover details.""" diff --git a/tests/test_queue_suggestion.py b/tests/test_queue_suggestion.py index a87e397..c4ec5af 100644 --- a/tests/test_queue_suggestion.py +++ b/tests/test_queue_suggestion.py @@ -53,16 +53,18 @@ def test_skips_existing_suggestion(self): def test_creates_suggestion_for_new_book(self): self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None self.mock_abs.get_item_details.return_value = { "media": {"metadata": {"title": "Test Book", "authorName": "Author"}} } - # No matches found, so suggestion creation won't save self.manager.queue_suggestion("book-789") self.mock_abs.get_item_details.assert_called_once_with("book-789") + self.mock_db.save_detected_book.assert_called_once() def test_thread_safety_prevents_duplicate(self): """Second concurrent call for same ID should be skipped.""" self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None # Simulate first call in-flight self.manager.suggestion_service._suggestion_in_flight.add("book-dup") @@ -73,6 +75,7 @@ def test_thread_safety_prevents_duplicate(self): def test_cleans_up_in_flight_on_error(self): self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None self.mock_abs.get_item_details.side_effect = Exception("boom") self.manager.queue_suggestion("book-err") diff --git a/tests/test_suggestion_logic.py b/tests/test_suggestion_logic.py index d025b3d..5a5ddbe 100644 --- a/tests/test_suggestion_logic.py +++ b/tests/test_suggestion_logic.py @@ -73,6 +73,7 @@ def test_suggestion_created_when_progress_low(self): self.mock_db.get_all_books.return_value = [] self.mock_db.get_pending_suggestion.return_value = None self.mock_db.suggestion_exists.return_value = False + self.mock_db.get_detected_book.return_value = None # Prepare successful suggestion creation mocks self.mock_abs.get_item_details.return_value = { @@ -86,6 +87,7 @@ def test_suggestion_created_when_progress_low(self): # Assert self.mock_db.save_pending_suggestion.assert_called_once() + self.mock_db.save_detected_book.assert_called_once() def test_suggestion_ignored_when_hidden(self): """Test that suggestions are NOT created if they were previously hidden."""