diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3dfac..05e1371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9719546..4c0bb1f 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/app.py b/app.py index 5b7df46..cd63477 100644 --- a/app.py +++ b/app.py @@ -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: @@ -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 @@ -611,6 +615,31 @@ def get_locale(): # 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" @@ -7268,6 +7297,7 @@ def api_scan(): 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 @@ -7774,6 +7804,8 @@ def api_process(): 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' @@ -7937,6 +7969,7 @@ def api_live_status(): 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/', methods=['POST']) @@ -7964,6 +7997,7 @@ def api_reject_fix(history_id): 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/', methods=['POST']) @@ -8010,6 +8044,7 @@ def api_apply_all_pending(): else: errors += 1 + log_action("apply_all", detail=f"applied={applied} errors={errors}", result="success") return jsonify({ 'success': True, 'applied': applied, @@ -8701,6 +8736,37 @@ def api_send_error_reports(): 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(): """ @@ -8753,12 +8819,14 @@ def api_analyze_path(): 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}) diff --git a/library_manager/feedback.py b/library_manager/feedback.py new file mode 100644 index 0000000..cf4c9c4 --- /dev/null +++ b/library_manager/feedback.py @@ -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 [] diff --git a/templates/base.html b/templates/base.html index 81de4bf..6108fbf 100644 --- a/templates/base.html +++ b/templates/base.html @@ -892,6 +892,110 @@