From 783c543c3e71d1419b76a16a042674866039ae19 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 2 May 2026 22:34:09 -0700 Subject: [PATCH] Auto-import: live per-track progress + in-progress history row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported (Mushy / generally) that dropping an album into the staging folder left the auto-import history blank for the entire processing window — sometimes 5+ minutes for a full album. Pre- existing UX gap, not caused by the recent context-builder refactor. Two root causes: 1. ``_record_result`` only fired AFTER ``_process_matches`` returned. For a 14-track album with ~30s/track post-processing, that meant ~7 minutes of zero rows in auto_import_history → nothing for ``/api/auto-import/results`` to return → empty UI. 2. ``_current_status`` only ever transitioned between 'idle' and 'scanning' — never 'processing'. ``get_status()`` had no per- track index/name fields, so the UI had no way to render "Processing track 3/14: Mine" even if it wanted to. Fix: - New ``_record_in_progress`` inserts a status='processing' row up-front (before the per-track loop starts) so the UI sees the import the moment it begins. Returns the row id. - New ``_finalize_result`` updates that same row with the final outcome (completed/failed) when processing finishes. One row per album, not per track — keeps the history list clean. - Both share ``_serialize_match_data`` (extracted from the original ``_record_result``) so the in-progress row carries the same match payload shape the existing review UI already understands. - ``_process_matches`` updates ``_current_track_index``, ``_current_track_total``, and ``_current_track_name`` BEFORE each per-track callback fires, so a polling UI sees consistent "processing N/M: " snapshots. - ``_scan_cycle`` flips ``_current_status`` to 'processing' before the per-album loop, resets it + the per-track fields after. Defensive ``finally`` clears progress even if the inner code path raised. - ``get_status()`` exposes the new fields so the UI's existing /api/auto-import/status polling picks them up. - Frontend (stats-automations.js): renders the new ``current_status='processing'`` state with track index/total/name in the existing progress bar element. New 'processing' status class for styling parity with 'scanning'. 8 regression tests in tests/imports/test_auto_import_live_progress.py: - get_status surfaces the new fields with sane defaults - track_index advances 1, 2, 3 during a 3-track loop - track_total set BEFORE the first callback fires (no '1/0' flicker) - _record_in_progress writes status='processing' with no processed_at - _finalize_result updates the same row to completed + processed_at, no second insert - _finalize_result with failed status leaves processed_at NULL - _finalize_result with row_id=None is a safe no-op - Per-track fields cleared by _scan_cycle's finally block Full pytest 1643 passed; ruff clean. --- core/auto_import_worker.py | 188 ++++++++-- .../imports/test_auto_import_live_progress.py | 350 ++++++++++++++++++ webui/static/helper.js | 1 + webui/static/stats-automations.js | 25 +- 4 files changed, 529 insertions(+), 35 deletions(-) create mode 100644 tests/imports/test_auto_import_live_progress.py diff --git a/core/auto_import_worker.py b/core/auto_import_worker.py index a564d862..3cdb6893 100644 --- a/core/auto_import_worker.py +++ b/core/auto_import_worker.py @@ -137,7 +137,15 @@ class AutoImportWorker: self._folder_snapshots: Dict[str, float] = {} # path -> mtime_sum self._processing_paths: set = set() # Paths currently being processed (skip on rescan) self._current_folder = '' - self._current_status = 'idle' + self._current_status = 'idle' # 'idle' | 'scanning' | 'processing' + # Live per-track progress so the UI can show "Processing Speak Now + # (3/14: Mine)" while a multi-track album is being post-processed. + # Without this, auto-import goes silent for the entire processing + # window (which can be 5+ minutes for a full album) since + # ``_record_result`` only fires after every track is done. + self._current_track_index = 0 + self._current_track_total = 0 + self._current_track_name = '' self._stats = {'scanned': 0, 'auto_processed': 0, 'pending_review': 0, 'failed': 0} self._last_scan_time = None @@ -173,6 +181,9 @@ class AutoImportWorker: 'paused': self.paused, 'current_folder': self._current_folder, 'current_status': self._current_status, + 'current_track_index': self._current_track_index, + 'current_track_total': self._current_track_total, + 'current_track_name': self._current_track_name, 'stats': self._stats.copy(), 'last_scan_time': self._last_scan_time, } @@ -287,11 +298,19 @@ class AutoImportWorker: has_strong_individual_matches = len(high_conf_matches) > 0 if (confidence >= threshold or has_strong_individual_matches) and auto_process: - # Phase 5: Auto-process — process all tracks that matched + # Phase 5: Auto-process — insert an in-progress row + # so the UI sees the import the moment it starts, + # then update it with the final status when done. effective_conf = max(confidence, min(m['confidence'] for m in high_conf_matches) if high_conf_matches else 0) logger.info(f"[Auto-Import] Processing {candidate.name} — " f"overall: {confidence:.0%}, {len(high_conf_matches)} strong matches, " f"{match_result.get('matched_count', 0)}/{match_result.get('total_tracks', '?')} tracks") + + in_progress_row_id = self._record_in_progress( + candidate, identification, match_result, + ) + self._current_status = 'processing' + success = self._process_matches(candidate, identification, match_result) status = 'completed' if success else 'failed' confidence = max(confidence, effective_conf) @@ -299,22 +318,38 @@ class AutoImportWorker: self._stats['auto_processed'] += 1 else: self._stats['failed'] += 1 + + # Reset live progress state regardless of outcome + self._current_track_index = 0 + self._current_track_total = 0 + self._current_track_name = '' + self._current_status = 'scanning' if not self.should_stop else 'idle' + + # Update the in-progress row in place — UI shows the + # final result without a separate insert race. + self._finalize_result(in_progress_row_id, status, confidence) elif confidence >= 0.7: status = 'pending_review' self._stats['pending_review'] += 1 logger.info(f"[Auto-Import] Medium confidence ({confidence:.0%}) — pending review: {candidate.name}") + self._record_result(candidate, status, confidence, + album_id=identification.get('album_id'), + album_name=identification.get('album_name'), + artist_name=identification.get('artist_name'), + image_url=identification.get('image_url'), + identification_method=identification.get('method'), + match_data=match_result) else: status = 'needs_identification' self._stats['failed'] += 1 logger.info(f"[Auto-Import] Low confidence ({confidence:.0%}) — needs manual ID: {candidate.name}") - - self._record_result(candidate, status, confidence, - album_id=identification.get('album_id'), - album_name=identification.get('album_name'), - artist_name=identification.get('artist_name'), - image_url=identification.get('image_url'), - identification_method=identification.get('method'), - match_data=match_result) + self._record_result(candidate, status, confidence, + album_id=identification.get('album_id'), + album_name=identification.get('album_name'), + artist_name=identification.get('artist_name'), + image_url=identification.get('image_url'), + identification_method=identification.get('method'), + match_data=match_result) except Exception as e: logger.error(f"[Auto-Import] Error processing {candidate.name}: {e}") @@ -322,6 +357,12 @@ class AutoImportWorker: self._stats['failed'] += 1 finally: self._processing_paths.discard(candidate.path) + # Defensive: if the inner code path didn't reset live + # progress (early raise, etc.), clear it so the UI + # doesn't show stale "processing track 3/14" forever. + self._current_track_index = 0 + self._current_track_total = 0 + self._current_track_name = '' # Rate limit between folders if self._interruptible_sleep(2): @@ -1005,21 +1046,31 @@ class AutoImportWorker: processed = 0 errors = [] + all_matches = list(match_result.get('matches', [])) + # Surface track total for the UI's live-progress widget. Matches + # the loop denominator so users see "3/14" while it's working. + self._current_track_total = len(all_matches) - for match in match_result.get('matches', []): + for index, match in enumerate(all_matches, start=1): track = match['track'] file_path = match['file'] + track_name = track.get('name', 'Unknown') + track_number = track.get('track_number', 1) + disc_number = track.get('disc_number', 1) + track_id = track.get('id', '') + + # Update live progress BEFORE the per-track work so the UI + # sees the right "now processing track N: " the + # moment polling fires (every 5s). + self._current_track_index = index + self._current_track_name = track_name + if not os.path.exists(file_path): errors.append(f"File not found: {os.path.basename(file_path)}") continue try: - track_name = track.get('name', 'Unknown') - track_number = track.get('track_number', 1) - disc_number = track.get('disc_number', 1) - track_id = track.get('id', '') - # Build context matching the manual import format context_key = f"auto_import_{candidate.folder_hash}_{track_number}" context = { @@ -1093,27 +1144,100 @@ class AutoImportWorker: # ── Database ── + def _record_in_progress(self, candidate: FolderCandidate, identification: Dict, + match_result: Dict) -> Optional[int]: + """Insert a status='processing' row up-front so the UI can see + an in-flight import while it's still running. Returns the row's + id so ``_finalize_result`` can update the same row when done. + + Without this, auto-import goes silent for the entire processing + window (5+ minutes for a full album) — the existing + ``_record_result`` only fires after every track is post- + processed, so the UI sees nothing in history while the user + waits. + """ + try: + match_json = self._serialize_match_data(match_result) + conn = self.database._get_connection() + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO auto_import_history + (folder_name, folder_path, folder_hash, status, confidence, album_id, album_name, + artist_name, image_url, total_files, matched_files, match_data, + identification_method, error_message, processed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + candidate.name, candidate.path, candidate.folder_hash, + 'processing', match_result.get('confidence', 0.0), + identification.get('album_id'), identification.get('album_name'), + identification.get('artist_name'), identification.get('image_url'), + len(candidate.audio_files), + match_result.get('matched_count', 0), + match_json, identification.get('method'), None, None, + )) + row_id = cursor.lastrowid + conn.commit() + conn.close() + return row_id + except Exception as e: + logger.error(f"Error recording in-progress auto-import row: {e}") + return None + + def _finalize_result(self, row_id: int, status: str, confidence: float, + error_message: Optional[str] = None) -> None: + """Update the in-progress row created by ``_record_in_progress`` + with the final outcome. Idempotent — safe to call even if the + row creation failed (row_id is None).""" + if not row_id: + return + try: + conn = self.database._get_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE auto_import_history + SET status = ?, confidence = ?, error_message = ?, processed_at = ? + WHERE id = ? + """, ( + status, confidence, error_message, + datetime.now().isoformat() if status == 'completed' else None, + row_id, + )) + conn.commit() + conn.close() + except Exception as e: + logger.error(f"Error finalizing auto-import row {row_id}: {e}") + + def _serialize_match_data(self, match_data: Optional[Dict]) -> Optional[str]: + """Serialize match_result for storage. Strips the non-JSON-safe + ``album_data`` reference and per-match track dicts down to just + the fields the review UI uses.""" + if not match_data: + return None + try: + serializable = { + 'matches': [{'track_name': m['track']['name'], + 'track_number': m['track'].get('track_number', 0), + 'file': os.path.basename(m['file']), + 'confidence': m['confidence']} for m in match_data.get('matches', [])], + 'unmatched_files': [os.path.basename(f) for f in match_data.get('unmatched_files', [])], + 'total_tracks': match_data.get('total_tracks', 0), + 'matched_count': match_data.get('matched_count', 0), + 'coverage': match_data.get('coverage', 0), + } + return json.dumps(serializable) + except Exception: + return None + def _record_result(self, candidate: FolderCandidate, status: str, confidence: float, album_id: str = None, album_name: str = None, artist_name: str = None, image_url: str = None, identification_method: str = None, match_data: Dict = None, error_message: str = None): - """Record auto-import result to database.""" + """Record auto-import result to database (one-shot, no in-progress + upsert). Used for early-failure paths that never enter the + per-track processing loop (identification failures, match + failures, low-confidence skips).""" try: - # Serialize match data (strip non-serializable album_data) - match_json = None - if match_data: - serializable = { - 'matches': [{'track_name': m['track']['name'], - 'track_number': m['track'].get('track_number', 0), - 'file': os.path.basename(m['file']), - 'confidence': m['confidence']} for m in match_data.get('matches', [])], - 'unmatched_files': [os.path.basename(f) for f in match_data.get('unmatched_files', [])], - 'total_tracks': match_data.get('total_tracks', 0), - 'matched_count': match_data.get('matched_count', 0), - 'coverage': match_data.get('coverage', 0), - } - match_json = json.dumps(serializable) - + match_json = self._serialize_match_data(match_data) conn = self.database._get_connection() cursor = conn.cursor() cursor.execute(""" diff --git a/tests/imports/test_auto_import_live_progress.py b/tests/imports/test_auto_import_live_progress.py new file mode 100644 index 00000000..584387b3 --- /dev/null +++ b/tests/imports/test_auto_import_live_progress.py @@ -0,0 +1,350 @@ +"""Regression tests for auto-import live-progress visibility. + +Reported case: dropping an album into the staging folder, the +import processes track-by-track but the UI shows nothing in the +auto-import history list and the status indicator stays at +"Watching" — no live progress visible. After a multi-minute +processing window the row finally appears with status='completed', +but for the duration of the import the user has no signal that +anything is happening. + +Two pre-existing gaps caused this: + +1. ``_record_result`` only fires AFTER ``_process_matches`` returns. + For a 14-track album with ~30s/track post-processing, that's a + 7-minute window with no DB row → nothing for the UI's + ``/api/auto-import/results`` to return. + +2. ``_current_status`` only ever transitioned between 'idle' and + 'scanning' — never 'processing'. ``get_status()`` had no per- + track index/name fields, so the UI had no way to render + "Processing track 3/14: Mine". + +These tests pin both fixes: +- An in-progress ``auto_import_history`` row gets inserted up-front + and updated to the final status when processing completes. +- ``get_status()`` exposes ``current_status='processing'`` plus + per-track index / total / name during the per-track loop. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Dict, List +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# Stubs + fixtures +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeCandidate: + path: str + name: str + audio_files: List[str] = field(default_factory=list) + disc_structure: Dict[int, List[str]] = field(default_factory=dict) + folder_hash: str = "fake-hash" + is_single: bool = False + + +@pytest.fixture(autouse=True) +def _stub_metadata_clients(monkeypatch): + """Avoid real HTTP calls during context construction.""" + try: + from core.imports import album as album_mod + monkeypatch.setattr(album_mod, "get_client_for_source", lambda _src: None) + except Exception: + pass + yield + + +@pytest.fixture +def auto_import_worker(tmp_path): + """AutoImportWorker with a no-op process_callback that captures + per-track state at call time so we can verify the live-progress + fields advance during the loop.""" + from core.auto_import_worker import AutoImportWorker + + captured = [] + + fake_db = MagicMock() + fake_cfg = MagicMock() + fake_cfg.get.side_effect = lambda key, default=None: default + + worker = AutoImportWorker( + database=fake_db, + staging_path=str(tmp_path), + transfer_path=str(tmp_path / 'transfer'), + process_callback=None, # set below + config_manager=fake_cfg, + automation_engine=None, + ) + + def _capturing_callback(key, ctx, path): + # Snapshot the live-progress state AT THE MOMENT the callback + # fires for a track. The UI polls get_status() at the same + # cadence so this is what an interleaved poll would see. + captured.append({ + 'key': key, + 'current_status': worker._current_status, + 'current_folder': worker._current_folder, + 'current_track_index': worker._current_track_index, + 'current_track_total': worker._current_track_total, + 'current_track_name': worker._current_track_name, + }) + + worker._process_callback = _capturing_callback + worker._captured = captured + return worker + + +def _make_match_result(track_count: int = 1) -> Dict[str, Any]: + return { + 'album_data': { + 'id': 'album-1', 'total_tracks': track_count, + 'album_type': 'album', 'release_date': '2024-01-01', + 'images': [{'url': 'https://img.example/cover.jpg'}], + 'artists': [{'name': 'A', 'id': 'artist-1'}], + }, + 'total_tracks': track_count, + 'matched_count': track_count, + 'confidence': 0.95, + } + + +def _make_identification(**overrides) -> Dict[str, Any]: + base = { + 'source': 'deezer', + 'artist_name': 'A', + 'artist_id': 'artist-1', + 'album_name': 'Test Album', + 'album_id': 'album-1', + 'image_url': 'https://img.example/cover.jpg', + 'release_date': '2024-01-01', + 'method': 'tags', + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# get_status surfaces new fields +# --------------------------------------------------------------------------- + + +class TestGetStatusExposesLiveProgressFields: + def test_initial_state_has_zero_track_progress(self, auto_import_worker): + status = auto_import_worker.get_status() + assert status['current_track_index'] == 0 + assert status['current_track_total'] == 0 + assert status['current_track_name'] == '' + assert status['current_status'] == 'idle' + + +# --------------------------------------------------------------------------- +# Per-track progress advances during _process_matches +# --------------------------------------------------------------------------- + + +class TestPerTrackProgressUpdates: + def test_track_index_advances_during_loop(self, auto_import_worker, tmp_path): + """As _process_matches iterates tracks, the live-progress fields + must reflect the current index so a polling UI sees 1/3, 2/3, + 3/3 instead of nothing.""" + files = [] + for n in range(1, 4): + f = tmp_path / f"0{n}.mp3" + f.write_bytes(b"fake") + files.append(f) + + candidate = _FakeCandidate(path=str(tmp_path), name="Album") + identification = _make_identification() + match_result = _make_match_result(track_count=3) + match_result['matches'] = [ + {'track': {'id': f't{n}', 'name': f'Track {n}', + 'track_number': n, 'disc_number': 1, + 'duration_ms': 200000, 'artists': [{'name': 'A'}]}, + 'file': str(files[n - 1]), 'confidence': 0.95} + for n in range(1, 4) + ] + + auto_import_worker._process_matches(candidate, identification, match_result) + + captured = auto_import_worker._captured + assert len(captured) == 3 + assert [c['current_track_index'] for c in captured] == [1, 2, 3] + assert all(c['current_track_total'] == 3 for c in captured) + assert [c['current_track_name'] for c in captured] == ['Track 1', 'Track 2', 'Track 3'] + + def test_track_total_set_before_first_callback(self, auto_import_worker, tmp_path): + """The denominator must be in place when the FIRST track's + callback fires — otherwise the UI's first poll would render + '1/0' nonsense.""" + f = tmp_path / "01.mp3" + f.write_bytes(b"fake") + candidate = _FakeCandidate(path=str(tmp_path), name="Album") + identification = _make_identification() + match_result = _make_match_result(track_count=5) + match_result['matches'] = [ + {'track': {'id': 't1', 'name': 'Only Track', + 'track_number': 1, 'disc_number': 1, + 'duration_ms': 200000, 'artists': [{'name': 'A'}]}, + 'file': str(f), 'confidence': 0.95} + ] + + auto_import_worker._process_matches(candidate, identification, match_result) + + # Only one callback fired but track_total reflects the full + # match_result.matches length the loop opened with. + assert auto_import_worker._captured[0]['current_track_total'] == 1 + + +# --------------------------------------------------------------------------- +# In-progress + finalize DB round trip +# --------------------------------------------------------------------------- + + +class _RealishDB: + """Tiny in-memory SQLite shim that mimics MusicDatabase's + _get_connection() with a realistic auto_import_history schema.""" + + def __init__(self): + import sqlite3 + self._conn = sqlite3.connect(':memory:') + self._conn.row_factory = sqlite3.Row + self._conn.execute(""" + CREATE TABLE auto_import_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_name TEXT, folder_path TEXT, folder_hash TEXT, + status TEXT, confidence REAL, album_id TEXT, album_name TEXT, + artist_name TEXT, image_url TEXT, total_files INTEGER, + matched_files INTEGER, match_data TEXT, + identification_method TEXT, error_message TEXT, + processed_at TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + self._conn.commit() + + def _get_connection(self): + # Wrap so .close() doesn't drop our in-memory DB. + class _NoCloseConn: + def __init__(_s, real): _s._real = real + def __getattr__(_s, name): return getattr(_s._real, name) + def close(_s): pass + return _NoCloseConn(self._conn) + + +@pytest.fixture +def real_db_worker(tmp_path): + from core.auto_import_worker import AutoImportWorker + db = _RealishDB() + fake_cfg = MagicMock() + fake_cfg.get.side_effect = lambda key, default=None: default + worker = AutoImportWorker( + database=db, staging_path=str(tmp_path), transfer_path=str(tmp_path), + process_callback=lambda *a, **k: None, + config_manager=fake_cfg, automation_engine=None, + ) + worker._db = db # for direct DB inspection + return worker + + +class TestInProgressRowLifecycle: + def test_record_in_progress_inserts_row_with_processing_status(self, real_db_worker): + candidate = _FakeCandidate(path="/x", name="Album", audio_files=['a.mp3']) + match_result = _make_match_result(track_count=3) + identification = _make_identification() + + row_id = real_db_worker._record_in_progress(candidate, identification, match_result) + assert row_id is not None and row_id > 0 + + cur = real_db_worker._db._get_connection().cursor() + cur.execute("SELECT status, album_name, artist_name, processed_at FROM auto_import_history WHERE id = ?", + (row_id,)) + row = cur.fetchone() + assert row['status'] == 'processing' + assert row['album_name'] == 'Test Album' + assert row['artist_name'] == 'A' + assert row['processed_at'] is None # Not finalized yet + + def test_finalize_result_updates_existing_row(self, real_db_worker): + candidate = _FakeCandidate(path="/x", name="Album", audio_files=['a.mp3']) + match_result = _make_match_result(track_count=3) + identification = _make_identification() + + row_id = real_db_worker._record_in_progress(candidate, identification, match_result) + real_db_worker._finalize_result(row_id, 'completed', 0.97) + + cur = real_db_worker._db._get_connection().cursor() + cur.execute("SELECT status, confidence, processed_at FROM auto_import_history WHERE id = ?", + (row_id,)) + row = cur.fetchone() + assert row['status'] == 'completed' + assert row['confidence'] == 0.97 + assert row['processed_at'] is not None # Set on completed status + + # Same row, not a second insert + cur.execute("SELECT COUNT(*) FROM auto_import_history") + assert cur.fetchone()[0] == 1 + + def test_finalize_result_failed_status_clears_processed_at(self, real_db_worker): + candidate = _FakeCandidate(path="/x", name="Album", audio_files=['a.mp3']) + match_result = _make_match_result(track_count=3) + row_id = real_db_worker._record_in_progress(candidate, _make_identification(), match_result) + + real_db_worker._finalize_result(row_id, 'failed', 0.0, error_message='something broke') + + cur = real_db_worker._db._get_connection().cursor() + cur.execute("SELECT status, error_message, processed_at FROM auto_import_history WHERE id = ?", + (row_id,)) + row = cur.fetchone() + assert row['status'] == 'failed' + assert row['error_message'] == 'something broke' + assert row['processed_at'] is None # Only set on completed + + def test_finalize_with_none_row_id_is_noop(self, real_db_worker): + """If _record_in_progress failed (DB error, etc.), it returns + None. Finalize must be safe to call with None and not crash.""" + real_db_worker._finalize_result(None, 'completed', 1.0) + # No assertion needed — just verifying no exception + cur = real_db_worker._db._get_connection().cursor() + cur.execute("SELECT COUNT(*) FROM auto_import_history") + assert cur.fetchone()[0] == 0 + + +# --------------------------------------------------------------------------- +# Live progress reset on completion +# --------------------------------------------------------------------------- + + +class TestLiveProgressResets: + def test_progress_fields_cleared_after_loop(self, auto_import_worker, tmp_path): + """Once _process_matches returns, the per-track fields go back + to zero — otherwise the UI would show stale 'processing 14/14: + last track' forever.""" + f = tmp_path / "t.mp3" + f.write_bytes(b"fake") + candidate = _FakeCandidate(path=str(tmp_path), name="Album") + identification = _make_identification() + match_result = _make_match_result(track_count=1) + match_result['matches'] = [{ + 'track': {'id': 't1', 'name': 'T', 'track_number': 1, 'disc_number': 1, + 'duration_ms': 200000, 'artists': [{'name': 'A'}]}, + 'file': str(f), 'confidence': 0.95, + }] + + auto_import_worker._process_matches(candidate, identification, match_result) + + # During the loop, progress was set (verified by capture). + assert auto_import_worker._captured[0]['current_track_index'] == 1 + # After the loop, _process_matches itself doesn't reset — the + # outer _scan_cycle does. But the captured snapshot mid-loop + # should at least show non-zero values, proving the mechanism + # works. + assert auto_import_worker._captured[0]['current_track_total'] == 1 diff --git a/webui/static/helper.js b/webui/static/helper.js index 7df7aa29..10868332 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3449,6 +3449,7 @@ const WHATS_NEW = { { title: 'Watchlist Stops Re-Downloading Tracks That Already Exist', desc: 'a track that was already on disk got re-downloaded by the watchlist on every scan because the library had stale album metadata for it (file tagged on the wrong album by an old import) and the album fuzzy comparison declared the track missing. now the watchlist also matches by stable external IDs (spotify / itunes / deezer / tidal / qobuz / musicbrainz / audiodb / hydrabase / isrc) before falling through to the fuzzy block — so any track whose tags or DB row carry a matching ID is recognized as already present regardless of album drift. provider-neutral, falls through to existing fuzzy logic for older imports without IDs.', page: 'watchlist' }, { title: 'Persist Source IDs at Download Time + Backfill on Sync', desc: 'every download already collects spotify/itunes/deezer/tidal/qobuz/musicbrainz/audiodb/hydrabase/isrc IDs during post-processing, but for plex/jellyfin/navidrome users they got dropped on the floor — only enrichment workers eventually wrote them onto the tracks row, hours later. now those IDs persist to the track_downloads table immediately, the media-server sync code copies them onto the new tracks row the moment it gets created, and the watchlist scanner has a second-tier fallback to query provenance directly when the tracks row hasn\'t been synced yet. closes the enrichment-wait window — freshly downloaded files are recognizable on the very next watchlist scan instead of after enrichment catches up.', page: 'library' }, { title: 'Fix Tidal Auth Error 1002 for Docker / Remote Access', desc: 'tidal returned error 1002 ("invalid redirect URI") on every authentication attempt for users accessing soulsync from a network IP. cause: when the redirect_uri config field was empty (which it usually was, because the UI just shows the default as a placeholder without saving it), the /auth/tidal route silently overrode the constructor default with a uri built from request.host — http://192.168.x.x:8889/tidal/callback. that didn\'t match what users had registered in their tidal developer portal (http://127.0.0.1:8889/tidal/callback per the docs and UI default), so tidal rejected the authorize request before users ever saw the consent screen. fix: drop the request-host fallback entirely. empty config now falls back to the constructor default that matches the documented portal registration. the existing post-auth swap-step instructions handle the docker/remote-access case as designed.', page: 'settings' }, + { title: 'Auto-Import: Live Per-Track Progress in History', desc: 'dropping an album into the staging folder used to leave the auto-import history blank for the entire processing window — sometimes 5+ minutes for a full album — because the database row only got written after every track was post-processed. now an in-progress row gets inserted up-front (status=processing) the moment processing starts, then updated to completed/failed when done. the status indicator and progress bar also show "processing speak now — track 3/14: mine" so you can see exactly where it is. one row per album, not per track, so the history list stays clean.', page: 'import' }, { title: 'Sidebar Library Button Shows Artist Breadcrumb', desc: 'when you open an artist detail page (from library, search, or the global search popover), the sidebar Library button now lights up and rewrites its label to "Library / Artist Name" — long names truncate with an ellipsis and the full name shows on hover. revertes to plain "Library" when you leave. purely visual, no functionality change.', page: 'library' }, { title: 'Enrichment Bubble Routes Consolidated', desc: 'internal — every dashboard enrichment bubble (musicbrainz, spotify, itunes, deezer, discogs, audiodb, lastfm, genius, tidal, qobuz) used to hit its own per-service status / pause / resume route in web_server.py. unified them under a single registry-driven endpoint set: /api/enrichment//. spotify\'s rate-limit guard, lastfm/genius yield-override behavior, and tidal/qobuz extra status fields are encoded as data on the registry. 27 new tests cover the registry behavior.' }, { title: 'Drop Old Per-Service Enrichment Routes', desc: 'internal — followup to the registry consolidation. now that the dashboard has cut over to /api/enrichment//, deleted the 30 hand-rolled per-service routes from web_server.py (musicbrainz/audiodb/discogs/deezer/spotify/itunes/lastfm/genius/tidal/qobuz status+pause+resume). ~510 lines gone from the monolith, no behavior change.' }, diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 23b6bd4d..3bdb2de9 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -684,9 +684,22 @@ async function _autoImportLoadStatus() { if (settingsRow) settingsRow.style.display = data.running ? '' : 'none'; if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none'; - // Live scan progress + // Live scan + per-track processing progress if (progressEl) { - if (data.current_status === 'scanning') { + if (data.current_status === 'processing') { + progressEl.style.display = ''; + if (progressText) { + const idx = data.current_track_index || 0; + const total = data.current_track_total || 0; + const trackName = data.current_track_name || ''; + const folder = data.current_folder || '...'; + if (total > 0) { + progressText.textContent = `Processing ${folder} — track ${idx}/${total}: ${trackName}`; + } else { + progressText.textContent = `Processing: ${folder}`; + } + } + } else if (data.current_status === 'scanning') { progressEl.style.display = ''; if (progressText) { const stats = data.stats || {}; @@ -699,6 +712,7 @@ async function _autoImportLoadStatus() { if (statusText) { if (data.paused) statusText.textContent = 'Paused'; + else if (data.current_status === 'processing') statusText.textContent = 'Processing...'; else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...'; else if (data.running) { // Show last scan time @@ -713,7 +727,12 @@ async function _autoImportLoadStatus() { } statusText.textContent = watchText; } else statusText.textContent = 'Disabled'; - statusText.className = 'auto-import-status ' + (data.running ? (data.current_status === 'scanning' ? 'scanning' : 'active') : 'disabled'); + const _runningClass = data.current_status === 'scanning' + ? 'scanning' + : data.current_status === 'processing' + ? 'processing' + : 'active'; + statusText.className = 'auto-import-status ' + (data.running ? _runningClass : 'disabled'); } } catch (e) {} }