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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

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

## [0.9.0-beta.123] - 2026-02-11

### Added

- **Issue #103: In-app hints and tooltips** - New `library_manager/hints.py` module with contextual
documentation for all features and settings. Hover over the (?) icon next to any setting to see a
plain-language explanation of what it does. Tooltips added to: all identification layers, AI
providers, confidence threshold, trust modes, safety toggles, watch folder, ebook management,
metadata embedding, community features, and more. Library page filter chips and action buttons also
show helpful tooltips on hover. Users never need to ask "what does this do?" again.

---

## [0.9.0-beta.122] - 2026-02-11

### Added
Expand Down
9 changes: 8 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.122"
APP_VERSION = "0.9.0-beta.123"
GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo

# Versioning Guide:
Expand Down Expand Up @@ -111,6 +111,7 @@
get_instance_data,
save_instance_data,
)
from library_manager.hints import get_all_hints

# Try to import P2P cache (optional - gracefully degrades if not available)
try:
Expand Down Expand Up @@ -698,7 +699,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
reports = json.load(f)
except:

Check failure on line 702 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (E722)

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

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

Check failure on line 726 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (E722)

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

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

Check failure on line 1681 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

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

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

Check failure on line 1690 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:1690: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 1696 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

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

else:
Expand Down Expand Up @@ -1794,7 +1795,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 1798 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:1798: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 @@ -2126,11 +2127,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 2130 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

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

Check failure on line 2132 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

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

Check failure on line 2134 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F541)

app.py:2134: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 @@ -2337,7 +2338,7 @@
if sample_path and os.path.exists(sample_path):
try:
os.unlink(sample_path)
except:

Check failure on line 2341 in app.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (E722)

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

return result
Expand Down Expand Up @@ -6761,6 +6762,12 @@
"""Inject worker_running into all templates automatically."""
return {'worker_running': is_worker_running()}


@app.context_processor
def inject_hints():
"""Inject hints dictionary into all templates for tooltips."""
return {'hints': get_all_hints()}

# ============== ROUTES ==============

@app.route('/')
Expand Down
118 changes: 118 additions & 0 deletions library_manager/hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""
In-app documentation hints for Library Manager.
Provides contextual help text for UI tooltips and hover explanations.
"""

HINTS = {
# === Identification Layers ===
'layer_1': 'Database Lookups: Searches Skaldleita, Audnexus, OpenLibrary, Google Books, and Hardcover for metadata matches. Free, fast, no API key needed.',
'layer_2': 'AI Verification: When databases return uncertain matches, AI (Gemini, OpenRouter, or Ollama) cross-checks the results. Uses your configured AI provider.',
'layer_3': 'Audio Analysis: Extracts the first 90 seconds of audio to identify the book from narrator intros and title announcements. Can use Skaldleita GPU or your own Gemini API.',
'layer_4': 'Content Analysis: Last resort. Transcribes story text with Whisper and sends it to AI to identify the book. Slowest but catches edge cases other layers miss.',

# === AI Providers ===
'skaldleita': 'Free GPU-powered audio identification service. Transcribes your audiobook intro and matches it against 50M+ books. Does not use your API quota.',
'gemini': 'Google Gemini AI. Free tier offers 14,400 calls/day with Gemma 3 models. Handles both text verification and native audio analysis.',
'openrouter': 'API gateway to multiple AI models. Free models available (Llama, Gemma). Used as fallback when Gemini is unavailable or for Layer 4 content analysis.',
'ollama': 'Self-hosted AI. Run models locally with no API costs or rate limits. Requires separate Ollama installation.',

# === Confidence & Verification ===
'confidence_threshold': 'Minimum confidence percentage before a book is considered identified. Higher values mean more certainty but slower processing. Lower values accept weaker matches faster.',
'confidence_percentage': 'How certain the system is about this identification. Built from multiple sources: audio analysis (85 weight), ID3 tags (80), metadata files (75), database lookups (65), AI (60), path analysis (40). Multiple agreeing sources boost confidence.',
'deep_scan_mode': 'Runs ALL enabled identification layers for every book, even if an earlier layer already found a confident match. Slower but more thorough.',

# === Status Meanings ===
'status_pending': 'A rename has been proposed. Review the suggested author/title and click Apply to rename, or Reject to dismiss.',
'status_verified': 'This book is already in the correct Author/Title folder. No changes needed.',
'status_fixed': 'This book was successfully renamed and moved to its new Author/Title location.',
'status_queued': 'Waiting to be identified. Will be processed automatically when the worker runs, or click Process Queue to start now.',
'status_error': 'Something went wrong during identification or renaming. Check the error message for details.',
'status_attention': 'Could not be auto-identified with enough confidence. Needs manual review - click Edit to set the correct author and title.',
'status_orphan': 'Loose audio files without a proper folder structure. Click Organize to move them into an Author/Title folder.',
'status_locked': 'Protected from automatic changes. Unlock to allow the system to process this book again.',
'status_duplicate': 'Multiple copies of the same book detected in your library.',
'status_reversed': 'Author and title folders appear swapped (e.g., Title/Author instead of Author/Title).',

# === Settings - Library Tab ===
'library_paths': 'Folders containing your audiobook library. Each path is scanned for book folders. Supports multiple paths (one per line).',
'naming_format': 'How renamed folders are structured. Author/Title works with Audiobookshelf, Plex, and Jellyfin. Custom templates let you include series, narrator, year, and more.',
'series_grouping': 'Groups series books under a shared folder: Author/Series Name/1 - Title. Keeps multi-book series organized together.',
'standardize_initials': 'Normalizes author initials to a consistent format (e.g., "JRR Tolkien" and "J.R.R. Tolkien" both become "J. R. R. Tolkien"). Prevents duplicate author folders.',
'strip_unabridged': 'Removes "(Unabridged)", "[Unabridged]", and similar markers from book titles during rename.',
'multilang_naming': 'Controls how non-English books are named. Native keeps the original language title. Preferred translates to your language. Tagged adds a language indicator.',

# === Settings - Watch Folder ===
'watch_folder': 'Monitors a folder for new audiobooks and automatically organizes them into your library. Great for processing downloads or imports.',
'watch_interval': 'How often (in seconds) to check the watch folder for new files.',
'watch_min_age': 'Minimum file age before processing. Prevents picking up files still being downloaded or copied.',
'watch_hard_links': 'Use hard links instead of moving files. Only works when watch folder and library are on the same filesystem. Saves disk space during processing.',

# === Settings - Processing Tab ===
'background_processing': 'Automatically processes queue items without manual intervention. Disable to only process when you click Process Queue.',
'scan_interval': 'Hours between automatic library scans. The system checks for new or changed books at this interval.',
'batch_size': 'Number of books processed in each batch. Higher values process faster but use more API calls at once.',
'max_requests_per_hour': 'Rate limit for API calls. Prevents hitting provider rate limits. Range: 10-500.',

# === Settings - AI Setup Tab ===
'gemini_api_key': 'Free API key from Google AI Studio (aistudio.google.com). Enables Gemini AI for text verification and audio analysis. 14,400 free calls per day.',
'openrouter_api_key': 'API key from openrouter.ai. Provides access to free AI models as fallback, and enables Layer 4 content analysis.',
'bookdb_api_key': 'Optional Skaldleita API key. Increases your rate limit from 500 to 1000 requests per hour. Free to register.',
'google_books_api_key': 'Optional Google Books API key for higher rate limits on book lookups.',
'ai_provider': 'Which AI to try first for text verification. Falls back to other configured providers automatically if the primary fails.',
'provider_chain': 'Order in which providers are tried. If the first one fails or is unavailable, the next one is used automatically.',

# === Settings - Safety Tab ===
'auto_fix': 'Automatically applies safe renames without asking. Only applies non-drastic changes (e.g., fixing capitalization). Drastic author changes still require approval.',
'protect_author_changes': 'When the author changes completely (e.g., "Unknown" to "Stephen King"), the fix is sent to Pending for manual review instead of auto-applying.',
'trust_the_process': 'YOLO mode. Auto-applies ALL changes when AI and audio analysis agree, including drastic author changes. No safety net. Back up your library first.',
'skip_confirmations': 'Removes "Are you sure?" popups when clicking Apply, Reject, or Undo. Faster workflow but no second chances.',

# === Settings - Advanced Tab ===
'metadata_embedding': 'Writes metadata tags (title, author, narrator, series) directly into audio files when fixes are applied. Supports MP3, M4B, FLAC, and Ogg.',
'ebook_management': 'Enables scanning and organizing ebook files (.epub, .mobi, .azw3, .pdf). Can merge ebooks into the same Author/Title folders as audiobooks or keep them separate.',
'isbn_lookup': 'Extracts ISBN from EPUB/PDF metadata for more accurate book matching.',
'error_reporting': 'Shares anonymous error reports to help improve Library Manager. Never includes file paths, API keys, or personal data.',
'community_contributions': 'Shares extracted metadata (author, title, narrator) with other Library Manager users. When 2+ users agree on metadata, it becomes verified for everyone.',
'p2p_cache': 'Shares book lookup results via a decentralized peer-to-peer network. Helps when Skaldleita is temporarily unavailable.',
'language_detection': 'Uses Gemini to detect the spoken language of audiobooks from audio samples.',
'strict_language_matching': 'Only matches books in your preferred language. Prevents cross-language mismatches (e.g., a Russian audiobook matching an English database entry).',
'preserve_original_titles': 'Keeps foreign language titles as-is instead of translating them to your preferred language.',
'deep_verification': 'Re-verifies your entire library against APIs, even books that look correctly named. Use when you suspect misattributed books in an imported collection.',

# === Trust Mode ===
'sl_trust_full': 'Accepts Skaldleita matches at 80%+ confidence and skips AI verification. Recommended - GPU Whisper with 50M book database is usually accurate.',
'sl_trust_boost': 'Uses Skaldleita results as a strong hint, then verifies with database APIs. Skips AI. Good middle ground.',
'sl_trust_legacy': 'Uses AI to verify uncertain Skaldleita matches. Most thorough but uses more API quota.',

# === Source Icons ===
'source_bookdb': 'Identified via Skaldleita - GPU-powered audio fingerprinting matched against 50M+ book database.',
'source_audio': 'Identified from audio analysis - narrator intro or title announcement detected.',
'source_ai': 'Verified by AI - an AI model confirmed the identification.',
'source_id3': 'Metadata from embedded ID3/audio tags in the file itself.',
'source_json': 'Metadata from a JSON sidecar file (e.g., metadata.json, info.json).',
'source_path': 'Inferred from the folder path and filename structure.',
'source_googlebooks': 'Matched via Google Books API.',
'source_openlibrary': 'Matched via OpenLibrary API.',
'source_audnexus': 'Matched via Audnexus (Audible metadata).',
'source_hardcover': 'Matched via Hardcover API (indie/modern books).',
'source_user': 'Manually set by user - overrides all other sources.',

# === Voice ID ===
'voice_id': 'Identifies narrators by voice fingerprint - like Shazam for audiobooks. Builds a community narrator library that improves over time.',

# === Misc UI ===
'free_badge': 'This feature is completely free - no API key or payment required.',
'uses_tokens_badge': 'This feature uses API calls from your configured provider. Check your provider dashboard for usage.',
'scan_library': 'Scans your library paths for new or changed audiobook folders. Does not process them - just discovers what needs to be identified.',
'process_queue': 'Starts processing all queued books through the identification pipeline (Layer 1 through Layer 4, depending on your settings).',
}


def get_hint(key: str, default: str = '') -> str:
"""Get a hint by key, returns empty string if not found."""
return HINTS.get(key, default)


def get_all_hints() -> dict:
"""Get all hints for template rendering."""
return HINTS.copy()
77 changes: 66 additions & 11 deletions templates/library.html
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,61 @@
opacity: 1;
color: #00d9ff;
}
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0, 217, 255, 0.4);
color: rgba(0, 217, 255, 0.6);
font-size: 9px;
font-weight: bold;
font-style: normal;
cursor: help;
margin-left: 3px;
position: relative;
vertical-align: middle;
line-height: 1;
}
.hint-icon:hover {
border-color: #00d9ff;
color: #00d9ff;
}
.hint-icon .hint-text {
display: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(15, 52, 96, 0.95);
border: 1px solid rgba(0, 217, 255, 0.3);
color: #eee;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.8rem;
font-weight: normal;
font-style: normal;
line-height: 1.4;
white-space: normal;
width: 260px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
pointer-events: none;
}
.hint-icon .hint-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 217, 255, 0.3);
}
.hint-icon:hover .hint-text {
display: block;
}
</style>

<!-- Filter Chips -->
Expand All @@ -95,42 +150,42 @@
<span class="ms-2">All</span>
<span class="count" id="count-all">0</span>
</a>
<a href="#" class="filter-chip" data-filter="pending" onclick="setFilter('pending'); return false;">
<a href="#" class="filter-chip" data-filter="pending" onclick="setFilter('pending'); return false;" title="{{ hints.status_pending }}">
<i class="bi bi-hourglass-split text-warning"></i>
<span class="ms-2">Pending</span>
<span class="count" id="count-pending">0</span>
</a>
<a href="#" class="filter-chip" data-filter="orphan" onclick="setFilter('orphan'); return false;">
<a href="#" class="filter-chip" data-filter="orphan" onclick="setFilter('orphan'); return false;" title="{{ hints.status_orphan }}">
<i class="bi bi-file-earmark-music text-info"></i>
<span class="ms-2">Orphans</span>
<span class="count" id="count-orphan">0</span>
</a>
<a href="#" class="filter-chip" data-filter="queue" onclick="setFilter('queue'); return false;">
<a href="#" class="filter-chip" data-filter="queue" onclick="setFilter('queue'); return false;" title="{{ hints.status_queued }}">
<i class="bi bi-list-task text-primary"></i>
<span class="ms-2">Queue</span>
<span class="count" id="count-queue">0</span>
</a>
<a href="#" class="filter-chip" data-filter="fixed" onclick="setFilter('fixed'); return false;" title="Books that were renamed/moved to new locations">
<a href="#" class="filter-chip" data-filter="fixed" onclick="setFilter('fixed'); return false;" title="{{ hints.status_fixed }}">
<i class="bi bi-check-circle text-success"></i>
<span class="ms-2">Fixed</span>
<span class="count" id="count-fixed">0</span>
</a>
<a href="#" class="filter-chip" data-filter="verified" onclick="setFilter('verified'); return false;" title="Books already in correct location - no changes needed">
<a href="#" class="filter-chip" data-filter="verified" onclick="setFilter('verified'); return false;" title="{{ hints.status_verified }}">
<i class="bi bi-patch-check text-success"></i>
<span class="ms-2">Verified</span>
<span class="count" id="count-verified">0</span>
</a>
<a href="#" class="filter-chip" data-filter="error" onclick="setFilter('error'); return false;">
<a href="#" class="filter-chip" data-filter="error" onclick="setFilter('error'); return false;" title="{{ hints.status_error }}">
<i class="bi bi-exclamation-triangle text-danger"></i>
<span class="ms-2">Errors</span>
<span class="count" id="count-error">0</span>
</a>
<a href="#" class="filter-chip" data-filter="attention" onclick="setFilter('attention'); return false;">
<a href="#" class="filter-chip" data-filter="attention" onclick="setFilter('attention'); return false;" title="{{ hints.status_attention }}">
<i class="bi bi-eye text-warning"></i>
<span class="ms-2">Attention</span>
<span class="count" id="count-attention">0</span>
</a>
<a href="#" class="filter-chip" data-filter="locked" onclick="setFilter('locked'); return false;">
<a href="#" class="filter-chip" data-filter="locked" onclick="setFilter('locked'); return false;" title="{{ hints.status_locked }}">
<i class="bi bi-lock-fill text-info"></i>
<span class="ms-2">Locked</span>
<span class="count" id="count-locked">0</span>
Expand Down Expand Up @@ -177,10 +232,10 @@
<div class="card mb-4">
<div class="card-body py-2">
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-primary btn-sm" onclick="scanLibrary()" id="btn-scan">
<button class="btn btn-primary btn-sm" onclick="scanLibrary()" id="btn-scan" title="{{ hints.scan_library }}">
<i class="bi bi-search"></i> Scan Library
</button>
<button class="btn btn-success btn-sm" onclick="processQueue()" id="btn-process">
<button class="btn btn-success btn-sm" onclick="processQueue()" id="btn-process" title="{{ hints.process_queue }}">
<i class="bi bi-play-fill"></i> Process Queue
</button>
<button class="btn btn-warning btn-sm" onclick="applyAllPending()" id="btn-apply-all" style="display: none;">
Expand Down Expand Up @@ -227,7 +282,7 @@
<th style="width: 14%; cursor: pointer; user-select: none;" onclick="toggleSort('status')" title="Sort by status">
Status <span class="sort-icon" id="sort-icon-status"></span>
</th>
<th style="width: 18%;">Details</th>
<th style="width: 18%;">Details <span class="hint-icon" aria-label="Help">?<span class="hint-text">{{ hints.confidence_percentage }}</span></span></th>
<th style="width: 13%;">Actions</th>
</tr>
</thead>
Expand Down
Loading