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: <name>" 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.
350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""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
|