From 3699f9c2289bb342a81003c3424f14d884155c67 Mon Sep 17 00:00:00 2001 From: DeuceBucket <51657177+deucebucket@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:36:01 -0500 Subject: [PATCH 01/20] Fix #169: Expand clean_search_title regex to handle special chars in author names (#178) The "by Author" stripping regex now handles periods (F. Scott Fitzgerald), apostrophes (O'Brien), hyphens (Mary-Jane), and trailing parenthetical content like (Audiobook)(Nonfiction). Co-authored-by: deucebucket --- library_manager/utils/naming.py | 2 +- test-env/test-naming-issues.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/library_manager/utils/naming.py b/library_manager/utils/naming.py index 92d1511..98c18b2 100644 --- a/library_manager/utils/naming.py +++ b/library_manager/utils/naming.py @@ -113,7 +113,7 @@ def clean_search_title(messy_name): # Remove file extensions clean = re.sub(r'\.(mp3|m4b|m4a|epub|pdf|mobi|webm|opus)$', '', clean, flags=re.IGNORECASE) # Remove "by Author" at the end temporarily for searching - clean = re.sub(r'\s+by\s+[\w\s]+$', '', clean, flags=re.IGNORECASE) + clean = re.sub(r'\s+by\s+[\w\s.\'\-]+(?:\s*\([^)]*\))*$', '', clean, flags=re.IGNORECASE) # Remove audiobook-related junk (YouTube rip artifacts) clean = re.sub(r'\b(full\s+)?audiobook\b', '', clean, flags=re.IGNORECASE) clean = re.sub(r'\b(complete|unabridged|abridged)\b', '', clean, flags=re.IGNORECASE) diff --git a/test-env/test-naming-issues.py b/test-env/test-naming-issues.py index 4e36e63..483e463 100644 --- a/test-env/test-naming-issues.py +++ b/test-env/test-naming-issues.py @@ -1422,6 +1422,38 @@ def is_book_like_folder(name): else: failed += 1 + # ========================================== + # Issue #169: clean_search_title "by Author" regex edge cases + # ========================================== + print("\n--- Issue #169: 'by Author' regex handles special characters ---") + + by_author_tests = [ + # Basic case (already worked) + ("The Great Gatsby by F Scott Fitzgerald", "The Great Gatsby"), + # Periods/initials in author name + ("The Great Gatsby by F. Scott Fitzgerald", "The Great Gatsby"), + ("A Space Odyssey by Arthur C. Clarke", "A Space Odyssey"), + # Apostrophes in author name + ("Leaving Las Vegas by John O'Brien", "Leaving Las Vegas"), + # Hyphens in author name + ("The Joy Luck Club by Mary-Jane Smith", "The Joy Luck Club"), + # Parenthetical content after author + ("LAST RITES by Ozzy Osbourne (Audiobook)(Nonfiction)", "LAST RITES"), + ("Some Book by Jane Doe (Unabridged)", "Some Book"), + # Combined edge cases + ("A Tale by J.R.R. Tolkien (Fantasy)", "A Tale"), + ("My Book by Mary O'Brien-Smith (Audiobook)", "My Book"), + ] + + for input_title, expected in by_author_tests: + result = clean_search_title(input_title) + if test_result(f"By-author strip: '{input_title}'", + result.strip() == expected, + f"Expected '{expected}', got '{result.strip()}'"): + passed += 1 + else: + failed += 1 + # ========================================== # Summary # ========================================== From d51dac576aed2023d2fbe51416c880c0a7ec4636 Mon Sep 17 00:00:00 2001 From: DeuceBucket <51657177+deucebucket@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:36:03 -0500 Subject: [PATCH 02/20] feat: Integrate file validation into scan pipeline (#110) (#179) * Fix #110: Integrate file validation into scan pipeline Wire up the existing file_validation.py module into deep_scan_library() so invalid audio files (corrupt, truncated, too short) are caught before they enter the processing queue. Changes: - database.py: Add validation_status and validation_reason columns to books table - config.py: Add enable_file_validation, min_audio_duration_seconds, min_audio_file_size_mb settings - file_validation.py: Accept configurable min_duration and min_size overrides - app.py: Import and call validate_audio_file at all 4 queue insertion points (loose files, flat books, series books, regular books). Invalid files get status='validation_failed' and are excluded from the queue. ffprobe unavailability is non-blocking (validation skipped gracefully). - app.py: Add validation_failed counts to dashboard, /api/stats, and /api/library - database.py: Add validation_failed to should_requeue_book skip list - dashboard.html: Show validation failure alert when count > 0 - settings.html: Add File Validation toggle with configurable thresholds * chore: Bump version to beta.135 for file validation integration (#110) --------- Co-authored-by: deucebucket --- CHANGELOG.md | 13 +++ README.md | 2 +- app.py | 146 ++++++++++++++++++++++++++++- library_manager/config.py | 6 +- library_manager/database.py | 13 ++- library_manager/file_validation.py | 17 +++- templates/dashboard.html | 15 +++ templates/settings.html | 27 ++++++ 8 files changed, 232 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 226fac1..ec063ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to Library Manager will be documented in this file. +## [0.9.0-beta.135] - 2026-03-10 + +### Added + +- **Issue #110: File validation in scan pipeline** - The existing `file_validation.py` module is now + integrated into the scan pipeline. Audio files are validated with ffprobe before queuing — corrupt, + truncated, or too-short files are marked `validation_failed` and skipped. Enabled by default, + requires ffprobe (gracefully skips if unavailable). Configurable thresholds in Settings: minimum + duration (default 10 min) and minimum file size (default 1 MB). Dashboard shows a warning when + validation failures exist. Books with `validation_failed` status are excluded from re-queuing. + +--- + ## [0.9.0-beta.134] - 2026-02-28 ### Fixed diff --git a/README.md b/README.md index 145c6ce..4ad36bb 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.134-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.9.0--beta.135-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 78562d3..aefc156 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ - Multi-provider AI (Gemini, OpenRouter, Ollama) """ -APP_VERSION = "0.9.0-beta.134" +APP_VERSION = "0.9.0-beta.135" GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo # Versioning Guide: @@ -118,6 +118,7 @@ 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.file_validation import validate_audio_file, check_ffmpeg_available from library_manager.hints import get_all_hints from library_manager.hooks import hooks_bp, run_hooks, build_hook_context @@ -4859,6 +4860,53 @@ def compare_book_folders(source_path, dest_path, deep_analysis=True): return result +def _validate_book_audio(book_path, config, ffmpeg_available): + """Issue #110: Validate a book's audio file before queueing. + + Finds the first audio file in the book folder and validates it. + + Args: + book_path: Path to the book folder (or file for loose files) + config: Configuration dictionary + ffmpeg_available: Whether ffprobe/ffmpeg are available + + Returns: + (status, reason) where: + - ('valid', 'valid') - file passed validation + - ('invalid', reason) - file failed validation + - ('skipped', reason) - validation was skipped (disabled or ffmpeg unavailable) + """ + if not config.get('enable_file_validation', True): + return 'skipped', 'validation_disabled' + + if not ffmpeg_available: + return 'skipped', 'ffprobe_not_available' + + # Determine audio file to validate + path = Path(book_path) + if path.is_file(): + audio_file = str(path) + else: + audio_file = get_first_audio_file(str(path)) + + if not audio_file: + return 'skipped', 'no_audio_file_found' + + # Get config thresholds + min_duration = config.get('min_audio_duration_seconds', 600) + min_size_mb = config.get('min_audio_file_size_mb', 1) + min_size_bytes = min_size_mb * 1_000_000 + + is_valid, reason, metadata = validate_audio_file( + audio_file, min_duration=min_duration, min_size=min_size_bytes + ) + + if is_valid: + return 'valid', 'valid' + else: + return 'invalid', reason + + def deep_scan_library(config): """ Deep scan library - the AUTISTIC LIBRARIAN approach. @@ -4870,9 +4918,18 @@ def deep_scan_library(config): checked = 0 # Total book folders examined scanned = 0 # New books added to tracking queued = 0 # Books added to fix queue + validation_counts = {'valid': 0, 'invalid': 0, 'skipped': 0} # Issue #110: File validation stats issues_found = {} # path -> list of issues triage_counts = {'clean': 0, 'messy': 0, 'garbage': 0} # Issue #110: Folder triage stats + # Issue #110: Check ffmpeg availability once at scan start + ffmpeg_available, ffmpeg_msg = check_ffmpeg_available() + if config.get('enable_file_validation', True): + if ffmpeg_available: + logger.info("[VALIDATION] ffprobe available - file validation enabled") + else: + logger.warning(f"[VALIDATION] {ffmpeg_msg} - file validation will be skipped") + # Track files for duplicate detection file_signatures = {} # signature -> list of paths file_names = {} # basename -> list of paths @@ -4940,6 +4997,17 @@ def deep_scan_library(config): book_id = c.lastrowid reset_layer = 1 # New books always start at layer 1 + # Issue #110: Validate audio file before queueing + v_status, v_reason = _validate_book_audio(path_str, config, ffmpeg_available) + validation_counts[v_status] = validation_counts.get(v_status, 0) + 1 + c.execute('UPDATE books SET validation_status = ?, validation_reason = ? WHERE id = ?', + (v_status, v_reason, book_id)) + if v_status == 'invalid': + c.execute('UPDATE books SET status = ? WHERE id = ?', ('validation_failed', book_id)) + conn.commit() + logger.info(f"[VALIDATION] Skipping invalid loose file ({v_reason}): {filename}") + continue + # Add to queue with special "loose_file" reason c.execute('''INSERT OR REPLACE INTO queue (book_id, reason, added_at, priority) @@ -5068,6 +5136,17 @@ def deep_scan_library(config): reset_layer = 1 logger.info(f"Added flat book: {flat_author} - {flat_title} (triage: {flat_triage})") + # Issue #110: Validate audio file before queueing + v_status, v_reason = _validate_book_audio(flat_path, config, ffmpeg_available) + validation_counts[v_status] = validation_counts.get(v_status, 0) + 1 + c.execute('UPDATE books SET validation_status = ?, validation_reason = ? WHERE id = ?', + (v_status, v_reason, flat_book_id)) + if v_status == 'invalid': + c.execute('UPDATE books SET status = ? WHERE id = ?', ('validation_failed', flat_book_id)) + conn.commit() + logger.info(f"[VALIDATION] Skipping invalid flat book ({v_reason}): {flat_author} - {flat_title}") + continue + # Queue for processing c.execute('SELECT id FROM queue WHERE book_id = ?', (flat_book_id,)) if not c.fetchone(): @@ -5236,6 +5315,17 @@ def deep_scan_library(config): scanned += 1 reset_layer = 1 + # Issue #110: Validate audio file before queueing + v_status, v_reason = _validate_book_audio(book_path, config, ffmpeg_available) + validation_counts[v_status] = validation_counts.get(v_status, 0) + 1 + c.execute('UPDATE books SET validation_status = ?, validation_reason = ? WHERE id = ?', + (v_status, v_reason, book_id)) + if v_status == 'invalid': + c.execute('UPDATE books SET status = ? WHERE id = ?', ('validation_failed', book_id)) + conn.commit() + logger.info(f"[VALIDATION] Skipping invalid series book ({v_reason}): {author}/{book_title}") + continue + # Queue for processing c.execute('SELECT id FROM queue WHERE book_id = ?', (book_id,)) if not c.fetchone(): @@ -5356,6 +5446,17 @@ def deep_scan_library(config): scanned += 1 reset_layer = 1 + # Issue #110: Validate audio file before queueing + v_status, v_reason = _validate_book_audio(path, config, ffmpeg_available) + validation_counts[v_status] = validation_counts.get(v_status, 0) + 1 + c.execute('UPDATE books SET validation_status = ?, validation_reason = ? WHERE id = ?', + (v_status, v_reason, book_id)) + if v_status == 'invalid': + c.execute('UPDATE books SET status = ? WHERE id = ?', ('validation_failed', book_id)) + conn.commit() + logger.info(f"[VALIDATION] Skipping invalid book ({v_reason}): {author}/{title}") + continue + # Add to queue if has issues if all_issues: # Skip multi-book collections - they need manual splitting, not renaming @@ -5412,6 +5513,7 @@ def deep_scan_library(config): logger.info(f"Queued: {queued} books need fixing") logger.info(f"Already correct: {checked - queued} books") logger.info(f"Folder triage: {triage_counts['clean']} clean, {triage_counts['messy']} messy, {triage_counts['garbage']} garbage") + logger.info(f"File validation: {validation_counts['valid']} valid, {validation_counts['invalid']} invalid, {validation_counts['skipped']} skipped") return checked, scanned, queued @@ -6923,6 +7025,10 @@ def dashboard(): WHERE h.status = 'pending_fix' ''') pending_fixes = c.fetchone()['count'] + # Issue #110: Count validation failures + c.execute("SELECT COUNT(*) as count FROM books WHERE validation_status = 'invalid'") + validation_failed_count = c.fetchone()['count'] + # Get recent history (use LEFT JOIN in case book was deleted) c.execute('''SELECT h.*, b.path FROM history h LEFT JOIN books b ON h.book_id = b.id @@ -6944,6 +7050,7 @@ def dashboard(): fixed_count=fixed_count, verified_count=verified_count, pending_fixes=pending_fixes, + validation_failed_count=validation_failed_count, recent_history=recent_history, daily_stats=daily_stats, config=config, @@ -7200,6 +7307,11 @@ def settings_page(): # P2P cache setting (Issue #62) config['enable_p2p_cache'] = 'enable_p2p_cache' in request.form + # Issue #110: File validation settings + config['enable_file_validation'] = 'enable_file_validation' in request.form + config['min_audio_duration_seconds'] = int(request.form.get('min_audio_duration_seconds', 600)) + config['min_audio_file_size_mb'] = int(request.form.get('min_audio_file_size_mb', 1)) + # Provider chain settings - parse comma-separated values into lists audio_chain_str = request.form.get('audio_provider_chain', 'bookdb,gemini').strip() config['audio_provider_chain'] = [p.strip() for p in audio_chain_str.split(',') if p.strip()] @@ -8722,6 +8834,10 @@ def api_stats(): c.execute("SELECT COUNT(*) as count FROM books WHERE status = 'verified'") verified = c.fetchone()['count'] + # Issue #110: Validation failure count + c.execute("SELECT COUNT(*) as count FROM books WHERE validation_status = 'invalid'") + validation_failed = c.fetchone()['count'] + conn.close() return jsonify({ @@ -8730,6 +8846,7 @@ def api_stats(): 'fixed': fixed, 'pending_fixes': pending, 'verified': verified, + 'validation_failed': validation_failed, 'worker_running': is_worker_running(), 'processing': get_processing_status() }) @@ -9230,6 +9347,10 @@ def build_order_by(sort_cols, default_order): c.execute("SELECT COUNT(*) FROM books WHERE user_locked = 1") counts['locked'] = c.fetchone()[0] + # Issue #110: Count validation failures + c.execute("SELECT COUNT(*) FROM books WHERE validation_status = 'invalid'") + counts['validation_failed'] = c.fetchone()[0] + # Count orphans (detected on-the-fly) orphan_list = [] for lib_path in config.get('library_paths', []): @@ -9468,6 +9589,27 @@ def build_order_by(sort_cols, default_order): 'user_locked': True }) + # Issue #110: Validation failed filter + elif status_filter == 'validation_failed': + order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') + c.execute('''SELECT id, path, current_author, current_title, status, updated_at, + validation_status, validation_reason + FROM books + WHERE validation_status = 'invalid' + ''' + order + ''' + LIMIT ? OFFSET ?''', (per_page, offset)) + for row in c.fetchall(): + items.append({ + 'id': row['id'], + 'type': 'book', + 'book_id': row['id'], + 'author': row['current_author'], + 'title': row['current_title'], + 'path': row['path'], + 'status': 'validation_failed', + 'validation_reason': row['validation_reason'] + }) + # Issue #53: Media type filters elif status_filter == 'audiobook_only': order = build_order_by(BOOK_SORT_COLS, 'current_author, current_title') @@ -9635,6 +9777,8 @@ def build_order_by(sort_cols, default_order): total = counts['attention'] elif status_filter == 'locked': total = counts['locked'] + elif status_filter == 'validation_failed': + total = counts['validation_failed'] elif status_filter == 'search': total = counts.get('search', 0) # Issue #53: Media type filters diff --git a/library_manager/config.py b/library_manager/config.py index a3b89cc..79338ab 100644 --- a/library_manager/config.py +++ b/library_manager/config.py @@ -136,7 +136,11 @@ def _detect_data_dir(): "watch_delete_empty_folders": True, # Remove empty source folders after moving "watch_min_file_age_seconds": 30, # Minimum file age before processing (wait for downloads to complete) # Post-processing hooks - run commands/webhooks after a book is renamed (Issue #166) - "post_processing_hooks": [] + "post_processing_hooks": [], + # Issue #110: File validation - check audio files before processing + "enable_file_validation": True, # Validate audio files with ffprobe before queueing + "min_audio_duration_seconds": 600, # Minimum duration (seconds) to consider a valid audiobook (default: 10 min) + "min_audio_file_size_mb": 1, # Minimum file size (MB) to consider a valid audiobook } DEFAULT_SECRETS = { diff --git a/library_manager/database.py b/library_manager/database.py index f09d68d..d29b161 100644 --- a/library_manager/database.py +++ b/library_manager/database.py @@ -153,6 +153,17 @@ def init_db(db_path=None): except: pass + # Issue #110: File validation columns - track whether audio files are valid + validation_columns = [ + ('validation_status', "TEXT"), # NULL=not validated, 'valid', 'invalid', 'skipped' + ('validation_reason', "TEXT"), # Why it failed (e.g., 'too_short', 'corrupt', 'no_audio_stream') + ] + for col_name, col_type in validation_columns: + try: + c.execute(f'ALTER TABLE books ADD COLUMN {col_name} {col_type}') + except: + pass # Column already exists + # Stats table - daily stats c.execute('''CREATE TABLE IF NOT EXISTS stats ( id INTEGER PRIMARY KEY, @@ -379,7 +390,7 @@ def should_requeue_book(book_row, max_retries=3): max_layer = max_layer or 0 # Never requeue these statuses - skip_statuses = {'user_locked', 'needs_attention', 'needs_split', 'series_folder', 'multi_book_files'} + skip_statuses = {'user_locked', 'needs_attention', 'needs_split', 'series_folder', 'multi_book_files', 'validation_failed'} if status in skip_statuses: return (False, None) diff --git a/library_manager/file_validation.py b/library_manager/file_validation.py index f55c91a..3157721 100644 --- a/library_manager/file_validation.py +++ b/library_manager/file_validation.py @@ -32,16 +32,27 @@ def check_ffmpeg_available() -> Tuple[bool, str]: return True, "ok" -def validate_audio_file(path: str) -> Tuple[bool, str, Dict[str, Any]]: +def validate_audio_file(path: str, min_duration: Optional[float] = None, + min_size: Optional[int] = None) -> Tuple[bool, str, Dict[str, Any]]: """ Validate an audio file using ffprobe. + Args: + path: Path to the audio file + min_duration: Minimum duration in seconds (default: MIN_DURATION_SECONDS) + min_size: Minimum file size in bytes (default: MIN_FILE_SIZE_BYTES) + Returns: (is_valid, reason, metadata) - is_valid: True if file is a valid audiobook - reason: "valid" or error description - metadata: Dict with duration, size, format info (empty if invalid) """ + if min_duration is None: + min_duration = MIN_DURATION_SECONDS + if min_size is None: + min_size = MIN_FILE_SIZE_BYTES + file_path = Path(path) # Basic checks with TOCTOU protection @@ -57,7 +68,7 @@ def validate_audio_file(path: str) -> Tuple[bool, str, Dict[str, Any]]: logger.warning(f"File disappeared during validation {path}: {e}") return False, "file_disappeared", {} - if file_size < MIN_FILE_SIZE_BYTES: + if file_size < min_size: return False, "too_small", {"size": file_size} # Run ffprobe @@ -137,7 +148,7 @@ def validate_audio_file(path: str) -> Tuple[bool, str, Dict[str, Any]]: if duration == 0: return False, "no_duration_truncated", metadata - if duration < MIN_DURATION_SECONDS: + if duration < min_duration: return False, "too_short", metadata # Try to seek to end (catches truncated files) diff --git a/templates/dashboard.html b/templates/dashboard.html index 3a72805..709d133 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -51,6 +51,21 @@ +{% if validation_failed_count is defined and validation_failed_count > 0 %} +
+
+ +
+
+{% endif %} +
+ + +
+
+
+
+ Processing Pipeline Order +
+
+

+ Drag layers to reorder, or use the arrow buttons. The pipeline processes books through each enabled layer in this order. +

+ + +
+ + +
+ + +
+ {% for layer in pipeline_layers %} +
+ + {{ loop.index }} +
+ {{ layer.layer_name }} +
{{ layer.description }} +
+
+
+ +
+ + +
+
+ {% endfor %} +
+ + + + +
+ +
+
+
+
+
@@ -2531,5 +2601,94 @@ // Initial render if hooks exist if (hooksData.length) renderHooksList(); else document.getElementById('hooks-empty') && (document.getElementById('hooks-empty').style.display = 'block'); + +// ==================== PIPELINE ORDER ==================== +var pipelineDefaultOrder = {{ pipeline_default_order | tojson }}; + +function updatePipelineOrderInput() { + var list = document.getElementById('pipeline-layer-list'); + if (!list) return; + var items = list.querySelectorAll('.pipeline-layer'); + var order = []; + items.forEach(function(item, idx) { + order.push(item.getAttribute('data-layer-id')); + var badge = item.querySelector('.pipeline-position'); + if (badge) badge.textContent = idx + 1; + }); + document.getElementById('pipeline_order_input').value = JSON.stringify(order); +} + +function movePipelineLayer(btn, direction) { + var item = btn.closest('.pipeline-layer'); + var list = item.parentNode; + var items = Array.from(list.querySelectorAll('.pipeline-layer')); + var idx = items.indexOf(item); + var newIdx = idx + direction; + if (newIdx < 0 || newIdx >= items.length) return; + if (direction === -1) { + list.insertBefore(item, items[newIdx]); + } else { + list.insertBefore(items[newIdx], item); + } + updatePipelineOrderInput(); +} + +function resetPipelineOrder() { + var list = document.getElementById('pipeline-layer-list'); + if (!list) return; + var items = Array.from(list.querySelectorAll('.pipeline-layer')); + // Sort by default order + items.sort(function(a, b) { + return pipelineDefaultOrder.indexOf(a.getAttribute('data-layer-id')) - pipelineDefaultOrder.indexOf(b.getAttribute('data-layer-id')); + }); + items.forEach(function(item) { list.appendChild(item); }); + updatePipelineOrderInput(); +} + +// Drag and drop for pipeline layers +(function() { + var dragItem = null; + var list = document.getElementById('pipeline-layer-list'); + if (!list) return; + + list.addEventListener('dragstart', function(e) { + dragItem = e.target.closest('.pipeline-layer'); + if (dragItem) { + dragItem.style.opacity = '0.5'; + e.dataTransfer.effectAllowed = 'move'; + } + }); + + list.addEventListener('dragend', function(e) { + if (dragItem) { + dragItem.style.opacity = '1'; + dragItem = null; + } + list.querySelectorAll('.pipeline-layer').forEach(function(el) { + el.style.borderTop = ''; + }); + }); + + list.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + var target = e.target.closest('.pipeline-layer'); + list.querySelectorAll('.pipeline-layer').forEach(function(el) { + el.style.borderTop = ''; + }); + if (target && target !== dragItem) { + target.style.borderTop = '2px solid #00d9ff'; + } + }); + + list.addEventListener('drop', function(e) { + e.preventDefault(); + var target = e.target.closest('.pipeline-layer'); + if (target && dragItem && target !== dragItem) { + list.insertBefore(dragItem, target); + updatePipelineOrderInput(); + } + }); +})(); {% endblock %} From d0e07b33a49fe68b0928d35933bbc760194afc0a Mon Sep 17 00:00:00 2001 From: DeuceBucket <51657177+deucebucket@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:01:50 -0500 Subject: [PATCH 07/20] feat: Add standalone layer execution API and UI (#66) (#184) * Addresses #66: Add standalone layer execution API and UI Adds POST /api/pipeline/run-layer/ endpoint that runs a single pipeline layer on demand (synchronous). Adds a play button next to each layer in the settings pipeline section with spinner feedback and result badges showing processed/resolved counts. * chore: Bump version to beta.137 for standalone layer execution (#66) --------- Co-authored-by: deucebucket --- CHANGELOG.md | 10 +++++ README.md | 2 +- app.py | 91 ++++++++++++++++++++++++++++++++++++++++- templates/settings.html | 61 +++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b61438..021d925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to Library Manager will be documented in this file. +## [0.9.0-beta.137] - 2026-03-10 + +### Added + +- **Issue #66: Standalone layer execution** - Play button next to each layer in the pipeline + settings section. Runs a single pipeline layer on demand via `POST /api/pipeline/run-layer/`. + Shows spinner during execution and result badge with processed/resolved counts. + +--- + ## [0.9.0-beta.136] - 2026-03-10 ### Added diff --git a/README.md b/README.md index 4ae8fde..2e4a0f4 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.136-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.9.0--beta.137-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 e51b4cd..8e2b42e 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ - Multi-provider AI (Gemini, OpenRouter, Ollama) """ -APP_VERSION = "0.9.0-beta.136" +APP_VERSION = "0.9.0-beta.137" GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo # Versioning Guide: @@ -8064,6 +8064,95 @@ def api_process_status(): return jsonify(get_processing_status()) +@app.route('/api/pipeline/run-layer/', methods=['POST']) +def api_run_single_layer(layer_id): + """Run a single pipeline layer on demand. + + Executes one batch of the specified layer synchronously. + The request blocks until the layer finishes processing its batch. + """ + global _bg_processing_active + + # Validate layer_id exists in registry + from library_manager.pipeline.registry import default_registry + layer_info = default_registry.get_layer(layer_id) + if layer_info is None: + return jsonify({ + 'success': False, + 'message': f'Unknown layer: {layer_id}' + }), 404 + + # Check that background processing is not currently running + if _bg_processing_active: + return jsonify({ + 'success': False, + 'message': 'Background processing is already running. Wait for it to finish.' + }), 409 + + config = load_config() + + # Check that the layer is enabled in config + if not config.get(layer_info.config_enable_key, True): + return jsonify({ + 'success': False, + 'message': f'Layer "{layer_info.layer_name}" is disabled. Enable it in settings first.' + }), 400 + + # Map layer_id to the corresponding processing function + layer_functions = { + 'audio_id': lambda: process_layer_1_audio(config), + 'audio_credits': lambda: process_layer_3_audio(config, verification_layer=2), + 'sl_requeue': lambda: process_sl_requeue_verification(config), + 'api_lookup': lambda: process_layer_1_api(config), + 'ai_verify': lambda: process_queue(config, verification_layer=4), + } + + layer_func = layer_functions.get(layer_id) + if layer_func is None: + return jsonify({ + 'success': False, + 'message': f'Layer "{layer_id}" does not have a processing function mapped.' + }), 501 + + # Update status to show this layer is running + update_processing_status('active', True) + update_processing_status('layer_name', layer_info.layer_name) + update_processing_status('current', f'Running {layer_info.layer_name} (manual)...') + + try: + processed, resolved = layer_func() + # process_queue returns -1 when rate-limited + if processed == -1: + processed = 0 + message = f'{layer_info.layer_name}: Rate limited, try again later.' + elif processed == 0: + message = f'{layer_info.layer_name}: No items to process at this layer.' + else: + message = f'{layer_info.layer_name}: {processed} processed, {resolved} resolved.' + + log_action("run_layer", detail=f"layer={layer_id} processed={processed} resolved={resolved}", result="success") + + return jsonify({ + 'success': True, + 'layer_id': layer_id, + 'layer_name': layer_info.layer_name, + 'processed': max(0, processed), + 'resolved': resolved, + 'message': message + }) + except Exception as e: + logger.error(f"Error running layer {layer_id}: {e}", exc_info=True) + return jsonify({ + 'success': False, + 'message': f'Error running {layer_info.layer_name}: {str(e)}' + }), 500 + finally: + update_processing_status('active', False) + update_processing_status('layer_name', 'Idle') + update_processing_status('current', 'Idle') + clear_current_book() + + @app.route('/api/live_status') def api_live_status(): """Get comprehensive live status for the status bar. diff --git a/templates/settings.html b/templates/settings.html index ced67bc..1684516 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -3,6 +3,12 @@ {% block content %} +