Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

All notable changes to Library Manager will be documented in this file.

## [0.9.0-beta.127] - 2026-02-16
## [0.9.0-beta.128] - 2026-02-16

### Added

- **Issue #113: User feedback and crash reporting** - New feedback system with floating button in
bottom-right corner. Users can submit bug reports, corrections, and feature requests with
optional session activity log and system info. Includes crash auto-prompt on 500 errors,
path/API key sanitization, and best-effort forwarding to Skaldleita API. Local storage in
`feedback.json` ensures feedback is never lost.
- **Issue #127: Path-based completion for partial results** - When Skaldleita returns truncated
names (e.g., "James S. A" instead of "James S. A. Corey"), the system now uses folder path
information to complete the full name. Also extracts series information from path structure
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Smart Audiobook Library Organizer with Multi-Source Metadata & AI Verification**

[![Version](https://img.shields.io/badge/version-0.9.0--beta.127-blue.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-0.9.0--beta.128-blue.svg)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://ghcr.io/deucebucket/library-manager)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)

Expand Down
70 changes: 69 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
- Multi-provider AI (Gemini, OpenRouter, Ollama)
"""

APP_VERSION = "0.9.0-beta.127"
APP_VERSION = "0.9.0-beta.128"
GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo

# Versioning Guide:
Expand Down Expand Up @@ -112,6 +112,10 @@
get_instance_data,
save_instance_data,
)
from library_manager.feedback import (
log_action, get_session_log, log_error as feedback_log_error,
get_system_info, store_feedback, sanitize_string as feedback_sanitize,
)
from library_manager.folder_triage import triage_folder, triage_book_path, should_use_path_hints, confidence_modifier
from library_manager.hints import get_all_hints

Expand Down Expand Up @@ -611,6 +615,31 @@
# Configuration code has been moved to library_manager/config.py
# Database code has been moved to library_manager/database.py

# ============== ERROR HANDLERS ==============

@app.errorhandler(500)
def handle_500(error):
feedback_log_error(error, context="500_error")
logger.error(f"Internal server error: {error}")
if request.path.startswith('/api/'):
return jsonify({
'success': False,
'error': 'Internal server error',
'suggest_feedback': True,
}), 500
return render_template('base.html',
config=load_config(),
worker_running=is_worker_running(),
), 500


@app.errorhandler(404)
def handle_404(error):
if request.path.startswith('/api/'):
return jsonify({'success': False, 'error': 'Not found'}), 404
return redirect('/')


# ============== ANONYMOUS ERROR REPORTING ==============

ERROR_REPORTS_PATH = DATA_DIR / "error_reports.json"
Expand Down Expand Up @@ -701,7 +730,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
reports = json.load(f)
except:

Check failure on line 733 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:733:13: E722 Do not use bare `except`
reports = []

# Add new report (keep last 100 reports to avoid file bloat)
Expand All @@ -725,7 +754,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
return json.load(f)
except:

Check failure on line 757 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:757:9: E722 Do not use bare `except`
return []
return []

Expand Down Expand Up @@ -1680,7 +1709,7 @@
continue
result = call_gemini(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with gemini")

Check failure on line 1712 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1712:33: F541 f-string without any placeholders
return result

elif provider == 'openrouter':
Expand All @@ -1689,13 +1718,13 @@
continue
result = call_openrouter(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with openrouter")

Check failure on line 1721 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1721:33: F541 f-string without any placeholders
return result

elif provider == 'ollama':
result = call_ollama(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with ollama")

Check failure on line 1727 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1727:33: F541 f-string without any placeholders
return result

else:
Expand Down Expand Up @@ -1797,7 +1826,7 @@
return result
elif result and result.get('transcript'):
# Got transcript but no match - still useful, return for potential AI fallback
logger.info(f"[AUDIO CHAIN] BookDB returned transcript only")

Check failure on line 1829 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1829:37: F541 f-string without any placeholders
return result
elif result is None and attempt < max_retries - 1:
# Connection might be down, wait and retry
Expand Down Expand Up @@ -2129,11 +2158,11 @@
device = "cuda"
# int8 works on all CUDA devices including GTX 1080 (compute 6.1)
# float16 only works on newer GPUs (compute 7.0+)
logger.info(f"[WHISPER] Using CUDA GPU acceleration (10x faster)")

Check failure on line 2161 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2161:29: F541 f-string without any placeholders
else:
logger.info(f"[WHISPER] Using CPU (no CUDA GPU detected)")

Check failure on line 2163 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2163:29: F541 f-string without any placeholders
except ImportError:
logger.info(f"[WHISPER] Using CPU (ctranslate2 not available)")

Check failure on line 2165 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2165:25: F541 f-string without any placeholders

_whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
_whisper_model_name = model_name
Expand Down Expand Up @@ -2340,7 +2369,7 @@
if sample_path and os.path.exists(sample_path):
try:
os.unlink(sample_path)
except:

Check failure on line 2372 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:2372:13: E722 Do not use bare `except`
pass

return result
Expand Down Expand Up @@ -7268,6 +7297,7 @@

config = load_config()
checked, scanned, queued = scan_library(config)
log_action("scan", detail=f"checked={checked} scanned={scanned} queued={queued}", result="success")
return jsonify({
'success': True,
'checked': checked, # Total book folders examined
Expand Down Expand Up @@ -7774,6 +7804,8 @@
update_processing_status('active', False)
clear_current_book()

log_action("process", detail=f"processed={processed} fixed={fixed} remaining={remaining}", result="success")

# Build helpful status message
if remaining == 0:
status = 'complete'
Expand Down Expand Up @@ -7937,6 +7969,7 @@
def api_apply_fix(history_id):
"""Apply a specific fix."""
success, message = apply_fix(history_id)
log_action("apply_fix", detail=f"history_id={history_id}", result="success" if success else "error")
return jsonify({'success': success, 'message': message})

@app.route('/api/reject_fix/<int:history_id>', methods=['POST'])
Expand Down Expand Up @@ -7964,6 +7997,7 @@
conn.close()

logger.info(f"Rejected fix {history_id}, book {book_id} marked as verified")
log_action("reject_fix", detail=f"history_id={history_id}", result="success")
return jsonify({'success': True})

@app.route('/api/dismiss_error/<int:history_id>', methods=['POST'])
Expand Down Expand Up @@ -8010,6 +8044,7 @@
else:
errors += 1

log_action("apply_all", detail=f"applied={applied} errors={errors}", result="success")
return jsonify({
'success': True,
'applied': applied,
Expand Down Expand Up @@ -8701,6 +8736,37 @@
return jsonify(result)


@app.route('/api/feedback', methods=['POST'])
def api_feedback():
data = request.get_json()
if not data:
return jsonify({'success': False, 'error': 'No data provided'}), 400
category = data.get('category', '').strip()
description = data.get('description', '').strip()
if not description:
return jsonify({'success': False, 'error': 'Description is required'}), 400
valid_categories = ['bug', 'correction', 'feature', 'other']
if category not in valid_categories:
category = 'other'
feedback_id = f"fb-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{get_instance_id()[-4:]}"
entry = {
"feedback_id": feedback_id,
"timestamp": datetime.now().isoformat(),
"instance_id": get_instance_id(),
"app_version": APP_VERSION,
"category": category,
"description": feedback_sanitize(description[:2000]),
}
if data.get('include_session_log', True):
entry["session_log"] = get_session_log()
if data.get('include_system_info', True):
entry["system_info"] = get_system_info(APP_VERSION)
result = store_feedback(entry)
if result.get('success'):
log_action('feedback_submit', detail=category, result='success')
return jsonify(result)


@app.route('/api/analyze_path', methods=['POST'])
def api_analyze_path():
"""
Expand Down Expand Up @@ -8753,12 +8819,14 @@
def api_start_worker():
"""Start background worker."""
start_worker()
log_action("worker_start", result="success")
return jsonify({'success': True})

@app.route('/api/worker/stop', methods=['POST'])
def api_stop_worker():
"""Stop background worker."""
stop_worker()
log_action("worker_stop", result="success")
return jsonify({'success': True})


Expand Down
213 changes: 213 additions & 0 deletions library_manager/feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""User feedback and session logging for Library Manager.

Provides:
- Session action logger (circular buffer of recent user actions)
- Path/data sanitizer (strips sensitive info)
- Feedback storage (local JSON + best-effort proxy to Skaldleita API)
"""
import json
import os
import re
import platform
import sys
import logging
import time
import traceback as tb_module
from collections import deque
from datetime import datetime
from threading import Lock

import requests

from .config import DATA_DIR
from .signing import generate_signature
from .providers.bookdb import get_lm_version

logger = logging.getLogger(__name__)

FEEDBACK_PATH = DATA_DIR / "feedback.json"
MAX_FEEDBACK_ENTRIES = 200
MAX_SESSION_LOG_SIZE = 50

# Thread-safe session log
_session_log = deque(maxlen=MAX_SESSION_LOG_SIZE)
_session_lock = Lock()

# Patterns to sanitize
_PATH_PATTERN = re.compile(
r'(/home/[^/\s"\']+|/Users/[^/\s"\']+|/mnt/[^/\s"\']+|'
r'/data|/audiobooks|[A-Z]:\\[^\s"\']+)'
)
_IP_PATTERN = re.compile(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b')
_API_KEY_PATTERN = re.compile(
r'(sk-[a-zA-Z0-9]{20,}|AIza[a-zA-Z0-9_-]{20,}|'
r'xkeysib-[a-zA-Z0-9-]+|Bearer\s+[a-zA-Z0-9._-]{20,})'
)


def sanitize_string(s):
"""Remove sensitive data from a string (paths, IPs, API keys)."""
if not s:
return s
s = _PATH_PATTERN.sub('[PATH]', str(s))
s = _IP_PATTERN.sub('[IP]', s)
s = _API_KEY_PATTERN.sub('[REDACTED]', s)
return s


def log_action(action_type, detail=None, book=None, path=None, result=None):
"""Log a user action to the session buffer.

Args:
action_type: Short action name (e.g., "scan", "apply_fix", "process")
detail: Optional extra detail string
book: Optional book title/author string
path: Optional affected path (will be sanitized)
result: Optional result string ("success", "error", etc.)
"""
entry = {
"ts": datetime.now().isoformat(),
"action": action_type,
}
if detail:
entry["detail"] = str(detail)[:200]
if book:
entry["book"] = str(book)[:150]
if path:
entry["path"] = sanitize_string(str(path))
if result:
entry["result"] = str(result)[:100]

with _session_lock:
_session_log.append(entry)


def get_session_log():
"""Return a copy of the current session log."""
with _session_lock:
return list(_session_log)


def clear_session_log():
"""Clear the session log."""
with _session_lock:
_session_log.clear()


def log_error(error, context=None):
"""Log an error event to the session buffer with sanitized traceback."""
tb_str = tb_module.format_exc()
entry = {
"ts": datetime.now().isoformat(),
"action": "error",
"detail": sanitize_string(str(error))[:300],
}
if context:
entry["context"] = str(context)[:100]
if tb_str and tb_str.strip() != "NoneType: None":
lines = tb_str.strip().split('\n')[-5:]
entry["traceback"] = [sanitize_string(line) for line in lines]

with _session_lock:
_session_log.append(entry)


def get_system_info(app_version):
"""Collect non-sensitive system info for feedback context."""
return {
"app_version": app_version,
"python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"os": platform.system(),
"os_version": platform.release(),
"arch": platform.machine(),
"docker": os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'),
}


FEEDBACK_API_URL = "https://bookdb.deucebucket.com/api/feedback"


def store_feedback(feedback_data):
"""Store feedback locally, then attempt to proxy to Skaldleita API.

Local storage is the primary method. The Skaldleita proxy is
best-effort -- feedback is never lost if the API is unreachable.

Args:
feedback_data: dict with category, description, and optional metadata

Returns:
dict with success status and feedback_id
"""
# Always store locally first
try:
entries = []
if FEEDBACK_PATH.exists():
try:
with open(FEEDBACK_PATH, 'r') as f:
entries = json.load(f)
except (json.JSONDecodeError, IOError):
entries = []

entries.append(feedback_data)
# Keep bounded
entries = entries[-MAX_FEEDBACK_ENTRIES:]

with open(FEEDBACK_PATH, 'w') as f:
json.dump(entries, f, indent=2)

feedback_id = feedback_data.get("feedback_id", "unknown")
logger.info(f"Feedback stored locally: {feedback_id}")

except (IOError, OSError, json.JSONDecodeError) as e:
logger.error(f"Failed to store feedback: {e}")
return {"success": False, "error": str(e)}

# Attempt to proxy to Skaldleita (best-effort)
proxied = _proxy_to_skaldleita(feedback_data)

return {
"success": True,
"feedback_id": feedback_id,
"proxied": proxied,
}


def _proxy_to_skaldleita(entry):
"""Forward feedback to Skaldleita API. Best-effort, never raises."""
try:
version = get_lm_version()
timestamp = str(int(time.time()))
headers = {
'User-Agent': f'LibraryManager/{version}',
'X-LM-Signature': generate_signature(version, timestamp),
'X-LM-Timestamp': timestamp,
'Content-Type': 'application/json',
}

resp = requests.post(
FEEDBACK_API_URL,
json=entry,
headers=headers,
timeout=10,
)
if resp.status_code == 200:
logger.info("Feedback proxied to Skaldleita successfully")
return True
else:
logger.debug(f"Skaldleita feedback API returned {resp.status_code}")
return False
except (requests.RequestException, KeyError, AttributeError) as e:
logger.debug(f"Failed to proxy feedback to Skaldleita: {e}")
return False


def get_stored_feedback():
"""Retrieve all locally stored feedback entries."""
if FEEDBACK_PATH.exists():
try:
with open(FEEDBACK_PATH, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
pass
return []
Loading