Merge pull request #379 from Nezreka/feat/reorganize-queue-and-status-panel

Library reorganize: FIFO queue with live status panel
This commit is contained in:
BoulderBadgeDad 2026-04-26 09:05:15 -07:00 committed by GitHub
commit 13b4578067
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2598 additions and 249 deletions

453
core/reorganize_queue.py Normal file
View file

@ -0,0 +1,453 @@
"""FIFO queue for library album reorganize requests.
Replaces the single-slot "one reorganize at a time, return 409 on
collision" model with a queue: clicks always succeed (or surface
"already queued" on dedupe), the user can fan-out clicks across
albums or hit "Reorganize All", and a single background worker
chews through the queue in submission order.
Design rules:
- **Single global queue**, single worker thread. Reorganize is
I/O-heavy (file copy, mutagen tagging, AcoustID, possibly ffmpeg)
and post-process is not designed for cross-album concurrency.
In-album track parallelism still happens inside `reorganize_album`
(3 worker threads see `_REORGANIZE_MAX_WORKERS`).
- **Dedupe on enqueue**: an album that's already queued or currently
running is rejected silently. Stops the user from spamming the
same album N times by clicking the button repeatedly.
- **Per-item source**: each queued item carries its own `source`
string (the user's per-album modal pick). Worker passes it
through to `reorganize_album(primary_source=..., strict_source=...)`.
- **Continue on failure**: a failed item doesn't stop the queue.
Worker logs the failure, marks the item `failed`, moves on.
- **Cancel queued items**: items in `queued` state can be cancelled
(drop from queue). The currently-running item can NOT be cancelled
mid-flight Python threads aren't cleanly killable, and post-
process spawns subprocesses we can't safely interrupt. Cancel
changes the item's status to `cancelled` and removes it from the
active queue.
- **In-memory only**: queue state lives in a module-level singleton.
A server restart loses the queue (in-flight item likely also lost
half-way through post-process). DB persistence is a follow-up if
this turns out to matter operationally.
"""
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from utils.logging_config import get_logger
logger = get_logger("reorganize_queue")
# How many recently-completed items to retain for the snapshot endpoint.
# The status panel uses these to show "just-finished" cards briefly so
# the user sees outcomes scroll past instead of items vanishing.
_RECENT_HISTORY_CAP = 30
@dataclass
class QueueItem:
"""One album waiting (or being processed) in the reorganize queue."""
queue_id: str # uuid; how the API references this item
album_id: str
album_title: str # captured at enqueue time for UI display
artist_id: Optional[str]
artist_name: str # captured at enqueue time for UI display
source: Optional[str] # the user's per-modal pick (None = auto)
enqueued_at: float
status: str = 'queued' # queued | running | done | failed | cancelled
started_at: Optional[float] = None
finished_at: Optional[float] = None
# Populated by the worker after each item finishes — surfaced to the
# status panel so users see counts + per-item error messages.
result_status: Optional[str] = None # mirrors `reorganize_album` summary['status']
result_source: Optional[str] = None # which source the orchestrator actually used
moved: int = 0
skipped: int = 0
failed: int = 0
error: Optional[str] = None # shorthand for the first error, for the toast
# Live-progress fields for the currently-running item; cleared when
# the worker moves on so the snapshot stays small.
current_track: Optional[str] = None
progress_total: int = 0
progress_processed: int = 0
def to_snapshot(self) -> dict:
return {
'queue_id': self.queue_id,
'album_id': self.album_id,
'album_title': self.album_title,
'artist_id': self.artist_id,
'artist_name': self.artist_name,
'source': self.source,
'enqueued_at': self.enqueued_at,
'started_at': self.started_at,
'finished_at': self.finished_at,
'status': self.status,
'result_status': self.result_status,
'result_source': self.result_source,
'moved': self.moved,
'skipped': self.skipped,
'failed': self.failed,
'error': self.error,
'current_track': self.current_track,
'progress_total': self.progress_total,
'progress_processed': self.progress_processed,
}
class ReorganizeQueue:
"""Module-level singleton that owns the queue + worker thread.
Use the module-level :func:`get_queue` accessor don't construct
directly. The class is documented public-style so tests can spin
up isolated instances.
"""
def __init__(self, *, runner: Optional[Callable[[QueueItem], dict]] = None):
"""
Args:
runner: Callable that takes a `QueueItem` and runs the
actual reorganize, returning a summary dict with
``status``, ``source``, ``moved``, ``skipped``,
``failed``, ``errors`` keys (the shape
``reorganize_album`` already returns). Tests inject
a fake runner; production wires the real one in
via :func:`set_runner`.
"""
# Single Condition variable owns both mutual exclusion and the
# idle-worker wait. Using a Condition (vs Lock + Event) closes a
# race where the worker could clear an event right after enqueue
# set it, causing the new item to sleep for the timeout window.
# cond.wait() releases the lock and re-acquires on notify, so
# state checks and waits are properly interleaved.
self._cond = threading.Condition()
self._items: List[QueueItem] = [] # everything ever submitted (active + recent)
self._runner = runner
self._worker: Optional[threading.Thread] = None
self._stopped = False
# -- public API --------------------------------------------------
def set_runner(self, runner: Callable[[QueueItem], dict]) -> None:
"""Inject the function that does the actual reorganize work.
Web_server calls this once at startup with a closure over the
injected dependencies (post-process fn, db, etc.)."""
with self._cond:
self._runner = runner
def enqueue(
self,
*,
album_id: str,
album_title: str,
artist_id: Optional[str],
artist_name: str,
source: Optional[str] = None,
) -> dict:
"""Add an album to the queue. Returns a result dict:
{'queued': True, 'queue_id': '...', 'position': N}
{'queued': False, 'reason': 'already_queued', 'queue_id': '...'}
Dedupe: if this album is already in `queued` or `running`
status, returns the existing entry's queue_id rather than
adding a duplicate. ``cancelled`` / ``done`` / ``failed``
items don't block re-enqueue (user retried after a failure).
"""
with self._cond:
for existing in self._items:
if existing.album_id == album_id and existing.status in ('queued', 'running'):
return {
'queued': False,
'reason': 'already_queued',
'queue_id': existing.queue_id,
}
item = QueueItem(
queue_id=uuid.uuid4().hex[:12],
album_id=album_id,
album_title=album_title,
artist_id=artist_id,
artist_name=artist_name,
source=source,
enqueued_at=time.time(),
)
self._items.append(item)
position = sum(1 for i in self._items if i.status == 'queued')
self._ensure_worker()
self._cond.notify_all()
logger.info(
f"[Queue] Enqueued '{album_title}' (album_id={album_id}, "
f"queue_id={item.queue_id}, position={position}, source={source or 'auto'})"
)
return {
'queued': True,
'queue_id': item.queue_id,
'position': position,
}
def enqueue_many(self, items: List[Dict[str, Any]]) -> Dict[str, int]:
"""Bulk-enqueue a list of items. Each ``item`` is a dict with
the same keys :meth:`enqueue` accepts (``album_id``,
``album_title``, ``artist_id``, ``artist_name``, ``source``).
Dedupe still applies per-album-id.
Holds the queue lock for the entire batch so two things hold:
(1) the worker can't start draining mid-batch, and (2) duplicate
album_ids inside the same batch get deduped against each other,
not just against pre-existing items. Without (2), a fast runner
could finish the first copy before the loop reached the second
and both would enqueue.
Returns a tally dict ``{'enqueued': N, 'already_queued': M,
'total': len(items)}`` so the caller can report bulk results
without doing the counting themselves. Used by the bulk
Reorganize-All endpoint and any future maintenance jobs that
enqueue at scale.
"""
enqueued = 0
already = 0
seen_in_batch: set = set()
with self._cond:
# Snapshot album_ids that already block re-enqueue so we don't
# rescan self._items per row.
blocked = {
i.album_id for i in self._items if i.status in ('queued', 'running')
}
for raw in items:
album_id = str(raw['album_id'])
if album_id in blocked or album_id in seen_in_batch:
already += 1
continue
seen_in_batch.add(album_id)
item = QueueItem(
queue_id=uuid.uuid4().hex[:12],
album_id=album_id,
album_title=raw.get('album_title') or 'Unknown Album',
artist_id=str(raw['artist_id']) if raw.get('artist_id') is not None else None,
artist_name=raw.get('artist_name') or 'Unknown Artist',
source=raw.get('source'),
enqueued_at=time.time(),
)
self._items.append(item)
enqueued += 1
logger.info(
f"[Queue] Bulk-enqueued '{item.album_title}' (album_id={album_id}, "
f"queue_id={item.queue_id}, source={item.source or 'auto'})"
)
if enqueued:
self._ensure_worker()
self._cond.notify_all()
return {'enqueued': enqueued, 'already_queued': already, 'total': len(items)}
def cancel(self, queue_id: str) -> dict:
"""Cancel a queued item. The currently-running item cannot be
cancelled (Python threads aren't cleanly killable; post-process
may have spawned ffmpeg)."""
with self._cond:
for item in self._items:
if item.queue_id != queue_id:
continue
if item.status == 'queued':
item.status = 'cancelled'
item.finished_at = time.time()
logger.info(f"[Queue] Cancelled queued item {queue_id} ('{item.album_title}')")
return {'cancelled': True}
if item.status == 'running':
return {'cancelled': False, 'reason': 'running_cant_cancel'}
return {'cancelled': False, 'reason': 'not_active'}
return {'cancelled': False, 'reason': 'not_found'}
def clear_queued(self) -> int:
"""Cancel ALL queued items (running item continues). Returns
the count of items cancelled."""
cancelled = 0
with self._cond:
now = time.time()
for item in self._items:
if item.status == 'queued':
item.status = 'cancelled'
item.finished_at = now
cancelled += 1
if cancelled:
logger.info(f"[Queue] Bulk-cancelled {cancelled} queued items")
return cancelled
def snapshot(self) -> dict:
"""Current queue state for the status panel. Returns:
{
'active': item dict | None,
'queued': [item dicts in FIFO order],
'recent': [item dicts in finish order, newest first, capped],
'totals': {'queued': N, 'running': M, 'done_today': K, ...},
}
"""
with self._cond:
active = next((i for i in self._items if i.status == 'running'), None)
queued = [i for i in self._items if i.status == 'queued']
recent = [i for i in self._items if i.status in ('done', 'failed', 'cancelled')]
recent.sort(key=lambda i: i.finished_at or 0, reverse=True)
recent = recent[:_RECENT_HISTORY_CAP]
return {
'active': active.to_snapshot() if active else None,
'queued': [i.to_snapshot() for i in queued],
'recent': [i.to_snapshot() for i in recent],
'totals': {
'queued': len(queued),
'running': 1 if active else 0,
'done': sum(1 for i in self._items if i.status == 'done'),
'failed': sum(1 for i in self._items if i.status == 'failed'),
'cancelled': sum(1 for i in self._items if i.status == 'cancelled'),
},
}
def stop(self) -> None:
"""Stop the worker (called on server shutdown)."""
with self._cond:
self._stopped = True
self._cond.notify_all()
# -- internals ---------------------------------------------------
def _ensure_worker(self) -> None:
"""Lazy worker start — only spawn the thread when there's
actually something to process. Caller MUST hold ``_cond``."""
if self._worker is not None and self._worker.is_alive():
return
self._worker = threading.Thread(
target=self._run, daemon=True, name='ReorganizeQueueWorker'
)
self._worker.start()
def _claim_next_or_wait(self) -> Optional[QueueItem]:
"""Atomically pick the next queued item AND flip it to 'running'
under a single lock acquisition. If the queue is empty, block
on ``_cond.wait()`` (which releases the lock while sleeping)
and return None when we're notified or timeout. Returning the
item already-marked-running closes the cancel-vs-run race: a
cancel() call now sees status='running' and is rejected."""
with self._cond:
while not self._stopped:
for item in self._items:
if item.status == 'queued':
item.status = 'running'
item.started_at = time.time()
return item
# No queued items — wait for an enqueue or shutdown.
# 60s timeout so a stuck notify (shouldn't happen, but
# defensive) doesn't park the worker forever.
self._cond.wait(timeout=60)
return None
def _run(self) -> None:
"""Worker loop: pull next queued, run it, mark done, repeat.
Idles on `_cond.wait()` when queue is empty."""
logger.info("[Queue] Worker thread started")
while not self._stopped:
item = self._claim_next_or_wait()
if item is None:
# Only happens on shutdown — `_claim_next_or_wait` only
# returns None once `_stopped` is True. Loop back to the
# `while not self._stopped` check, which exits.
continue
logger.info(f"[Queue] Starting '{item.album_title}' (queue_id={item.queue_id})")
try:
runner = self._runner
if runner is None:
raise RuntimeError("Queue has no runner configured — call set_runner() at startup")
summary = runner(item)
except Exception as e:
logger.error(
f"[Queue] Runner raised for '{item.album_title}': {e}",
exc_info=True,
)
with self._cond:
item.status = 'failed'
item.error = str(e)
item.finished_at = time.time()
continue
with self._cond:
item.moved = int(summary.get('moved', 0))
item.skipped = int(summary.get('skipped', 0))
item.failed = int(summary.get('failed', 0))
item.result_status = summary.get('status')
item.result_source = summary.get('source')
errors = summary.get('errors') or []
if errors:
first_err = errors[0] if isinstance(errors[0], dict) else {'error': str(errors[0])}
item.error = first_err.get('error') or first_err.get('reason')
# 'failed' status only when the run produced concrete failed tracks
# OR ended in a non-completed state (no_source_id / no_album / etc).
item.status = 'failed' if (item.failed > 0 or item.result_status not in (None, 'completed')) else 'done'
item.finished_at = time.time()
# Clear live-progress fields — done items don't need them.
item.current_track = None
item.progress_total = 0
item.progress_processed = 0
logger.info(
f"[Queue] Finished '{item.album_title}' — status={item.status}, "
f"moved={item.moved}, skipped={item.skipped}, failed={item.failed}"
)
logger.info("[Queue] Worker thread exiting")
# Called by the runner (or test) to push live progress onto the
# currently-running item. Safe to call from worker thread inside
# reorganize_album's on_progress callback.
def update_active_progress(self, *, queue_id: str, **fields) -> None:
with self._cond:
for item in self._items:
if item.queue_id == queue_id and item.status == 'running':
if 'current_track' in fields:
item.current_track = fields['current_track']
if 'total' in fields:
item.progress_total = int(fields['total'])
if 'processed' in fields:
item.progress_processed = int(fields['processed'])
if 'moved' in fields:
item.moved = int(fields['moved'])
if 'skipped' in fields:
item.skipped = int(fields['skipped'])
if 'failed' in fields:
item.failed = int(fields['failed'])
return
# Module-level singleton accessor ---------------------------------------------
_singleton: Optional[ReorganizeQueue] = None
_singleton_lock = threading.Lock()
def get_queue() -> ReorganizeQueue:
global _singleton
with _singleton_lock:
if _singleton is None:
_singleton = ReorganizeQueue()
return _singleton
def reset_queue_for_tests() -> None:
"""Test-only: drop the singleton so the next get_queue() returns
a fresh instance. Production code never calls this."""
global _singleton
with _singleton_lock:
if _singleton is not None:
_singleton.stop()
_singleton = None

123
core/reorganize_runner.py Normal file
View file

@ -0,0 +1,123 @@
"""Builds the per-item runner closure that the reorganize queue worker
invokes. Lives outside ``web_server`` so the wiring is unit-testable
and the monolith stays small.
The runner ties three subsystems together:
* :func:`core.library_reorganize.reorganize_album` the orchestrator
that copies files to staging, matches them against the metadata
source, and routes each through the post-process pipeline.
* :func:`core.reorganize_queue.get_queue` the queue this runner is
registered with; we forward live progress updates back into the
active queue item so the status panel can show per-track state.
* The dependency callbacks injected by ``web_server`` (DB accessor,
resolve-file-path, post-process function, empty-dir cleanup,
shutdown signal). These are passed in rather than imported so the
module stays testable in isolation.
Config (download path / transfer path) is read **per run**, not at
module load. That way a user changing their download path in settings
takes effect on the next reorganize without needing a server restart.
"""
import os
from typing import Callable, Optional
from utils.logging_config import get_logger
logger = get_logger("reorganize_runner")
def build_runner(
*,
get_database: Callable[[], object],
resolve_file_path_fn: Callable[[Optional[str]], Optional[str]],
post_process_fn: Callable[[str, dict, str], None],
cleanup_empty_directories_fn: Callable[[str, str], None],
is_shutting_down_fn: Callable[[], bool],
get_download_path: Callable[[], str],
get_transfer_path: Callable[[], str],
) -> Callable[[object], dict]:
"""Return the closure the queue worker invokes per item.
Args:
get_database: Returns the live MusicDatabase singleton.
resolve_file_path_fn: Resolves a DB-stored file path to the
actual on-disk path (or ``None`` if missing).
post_process_fn: ``_post_process_matched_download``. Must set
``context['_final_processed_path']`` on success.
cleanup_empty_directories_fn: Called as
``cleanup_empty_directories_fn(transfer_dir, marker_path)``
to prune empty source dirs after a track is moved.
is_shutting_down_fn: Returns True when the server is shutting
down so the orchestrator can abort early.
get_download_path: Resolves the user's configured download
path *at call time* (so config changes apply live).
get_transfer_path: Same, for the transfer path.
Returns:
A callable ``runner(item)`` suitable for
:meth:`core.reorganize_queue.ReorganizeQueue.set_runner`.
"""
from core.library_reorganize import reorganize_album
from core.reorganize_queue import get_queue
def _update_track_path(track_id, new_path):
try:
db = get_database()
with db._get_connection() as conn:
conn.execute(
"UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_path, str(track_id)),
)
conn.commit()
except Exception as db_err:
logger.warning(f"[Reorganize] DB path update failed for {track_id}: {db_err}")
def runner(item):
# Read config per-run so the user changing their download path
# in Settings takes effect on the next reorganize without a
# server restart.
download_dir = get_download_path()
transfer_dir = get_transfer_path()
staging_root = os.path.join(download_dir, 'ssync_staging')
try:
os.makedirs(staging_root, exist_ok=True)
except OSError as mk_err:
logger.error(f"[Reorganize] Cannot create staging dir {staging_root}: {mk_err}")
return {
'status': 'setup_failed',
'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0,
'errors': [{'error': f'Could not create staging dir: {mk_err}'}],
}
def _cleanup_empty(src_dir):
try:
cleanup_empty_directories_fn(transfer_dir, os.path.join(src_dir, '_'))
except Exception:
pass
def _on_progress(updates):
try:
get_queue().update_active_progress(queue_id=item.queue_id, **updates)
except Exception:
# Progress fan-out failures must never break a run.
pass
return reorganize_album(
album_id=item.album_id,
db=get_database(),
staging_root=staging_root,
resolve_file_path_fn=resolve_file_path_fn,
post_process_fn=post_process_fn,
update_track_path_fn=_update_track_path,
cleanup_empty_dir_fn=_cleanup_empty,
transfer_dir=transfer_dir,
on_progress=_on_progress,
primary_source=item.source,
strict_source=bool(item.source),
stop_check=is_shutting_down_fn,
)
return runner

View file

@ -4795,6 +4795,63 @@ class MusicDatabase:
logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
return False
def get_album_display_meta(self, album_id) -> Optional[Dict[str, Any]]:
"""Return ``{album_title, artist_id, artist_name}`` for an album row.
Used by the reorganize queue enqueue endpoint to capture display
strings at submission time so the status panel can render
without a DB lookup per poll. Returns None when the album row
does not exist; lets DB errors bubble up so callers can surface
a real failure instead of swallowing it as "album not found".
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT al.title AS album_title,
ar.id AS artist_id,
ar.name AS artist_name
FROM albums al
JOIN artists ar ON al.artist_id = ar.id
WHERE al.id = ?
""",
(str(album_id),),
)
row = cursor.fetchone()
if not row:
return None
return {
'album_title': row['album_title'] or 'Unknown Album',
'artist_id': str(row['artist_id']) if row['artist_id'] is not None else None,
'artist_name': row['artist_name'] or 'Unknown Artist',
}
def get_artist_albums_for_reorganize(self, artist_id) -> List[Dict[str, Any]]:
"""Return ``[{album_id, album_title, artist_id, artist_name}, ...]``
for every album owned by ``artist_id``, ordered by year then
title. Used by the bulk Reorganize-All endpoint to pull the
full tracklist server-side instead of trusting whatever the
frontend cached. Returns an empty list when the artist has no
albums; lets DB errors bubble so a real failure surfaces as a
500 rather than masquerading as "no albums found".
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT al.id AS album_id,
al.title AS album_title,
ar.id AS artist_id,
ar.name AS artist_name
FROM albums al
JOIN artists ar ON al.artist_id = ar.id
WHERE ar.id = ?
ORDER BY al.year ASC, al.title ASC
""",
(str(artist_id),),
)
return [dict(r) for r in cursor.fetchall()]
def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
"""Get all albums by artist ID"""
try:

View file

@ -0,0 +1,213 @@
"""Tests for the reorganize-queue DB helpers on `MusicDatabase`:
- ``get_album_display_meta(album_id)`` returns the title/artist tuple
the queue uses for status-panel display, or None when not found.
- ``get_artist_albums_for_reorganize(artist_id)`` returns the
bulk-enqueue list ordered by year then title.
These are isolated DB-method tests so the SQL itself is verified
without spinning up Flask, the queue worker, or the orchestrator.
"""
import sqlite3
import sys
import types
import pytest
# ── stubs (same shape used elsewhere in the test suite) ───────────────────
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from database.music_database import MusicDatabase # noqa: E402
# ── helpers ───────────────────────────────────────────────────────────────
class _InMemoryDB(MusicDatabase):
"""MusicDatabase that uses an in-memory sqlite that survives across
`_get_connection()` calls. Lets tests seed rows once and have the
methods under test see them."""
def __init__(self):
# Skip the real __init__ — it would try to migrate a real db.
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
def _get_connection(self):
return _NonClosingConn(self._conn)
class _NonClosingConn:
"""Wraps the shared sqlite connection so `with db._get_connection()
as conn:` doesn't close the underlying handle between calls."""
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
def _seed(db, *, artists=(), albums=()):
cur = db._conn.cursor()
cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)")
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
year INTEGER
)
""")
for ar in artists:
cur.execute("INSERT INTO artists VALUES (?, ?)", ar)
for al in albums:
cur.execute(
"INSERT INTO albums (id, artist_id, title, year) VALUES (?, ?, ?, ?)",
al,
)
db._conn.commit()
@pytest.fixture
def db():
return _InMemoryDB()
# ── get_album_display_meta ────────────────────────────────────────────────
def test_get_album_display_meta_returns_dict_for_known_album(db):
_seed(db,
artists=[('ar-1', 'Kendrick Lamar')],
albums=[('alb-1', 'ar-1', 'good kid, m.A.A.d city', 2012)])
meta = db.get_album_display_meta('alb-1')
assert meta == {
'album_title': 'good kid, m.A.A.d city',
'artist_id': 'ar-1',
'artist_name': 'Kendrick Lamar',
}
def test_get_album_display_meta_returns_none_for_missing_album(db):
_seed(db, artists=[('ar-1', 'Aerosmith')])
assert db.get_album_display_meta('does-not-exist') is None
def test_get_album_display_meta_falls_back_for_blank_strings(db):
"""Albums with empty title or artist name in the DB still need a
safe display value the queue UI should never render '(blank)'."""
_seed(db,
artists=[('ar-1', '')],
albums=[('alb-1', 'ar-1', '', 2015)])
meta = db.get_album_display_meta('alb-1')
assert meta['album_title'] == 'Unknown Album'
assert meta['artist_name'] == 'Unknown Artist'
assert meta['artist_id'] == 'ar-1'
# ── get_artist_albums_for_reorganize ──────────────────────────────────────
def test_get_artist_albums_for_reorganize_orders_by_year_then_title(db):
_seed(db,
artists=[('ar-1', 'Aerosmith')],
albums=[
('alb-c', 'ar-1', 'Toys in the Attic', 1975),
('alb-a', 'ar-1', 'Aerosmith', 1973),
('alb-b', 'ar-1', 'Get Your Wings', 1974),
])
rows = db.get_artist_albums_for_reorganize('ar-1')
assert [r['album_id'] for r in rows] == ['alb-a', 'alb-b', 'alb-c']
assert all(r['artist_name'] == 'Aerosmith' for r in rows)
def test_get_artist_albums_for_reorganize_secondary_sorts_by_title(db):
"""Same release year → tiebreak on title alphabetically."""
_seed(db,
artists=[('ar-1', 'X')],
albums=[
('alb-z', 'ar-1', 'Zebra', 1990),
('alb-a', 'ar-1', 'Apple', 1990),
('alb-m', 'ar-1', 'Mango', 1990),
])
rows = db.get_artist_albums_for_reorganize('ar-1')
assert [r['album_title'] for r in rows] == ['Apple', 'Mango', 'Zebra']
def test_get_artist_albums_for_reorganize_returns_empty_for_unknown_artist(db):
_seed(db, artists=[('ar-1', 'Aerosmith')])
assert db.get_artist_albums_for_reorganize('not-a-real-artist') == []
def test_get_artist_albums_for_reorganize_isolates_by_artist(db):
"""Pulling albums for artist A must NOT leak in albums from artist B."""
_seed(db,
artists=[('ar-1', 'A'), ('ar-2', 'B')],
albums=[
('alb-1', 'ar-1', 'A1', 2000),
('alb-2', 'ar-2', 'B1', 2000),
('alb-3', 'ar-1', 'A2', 2001),
])
rows = db.get_artist_albums_for_reorganize('ar-1')
assert {r['album_id'] for r in rows} == {'alb-1', 'alb-3'}
# ── error propagation ────────────────────────────────────────────────────
# Regression for review feedback on the original PR: helpers used to
# swallow every Exception and return None / [], so a real DB outage
# masqueraded as "album not found" / "no albums". Now they let the
# error bubble — the route layer turns it into a 500 — so the user sees
# a real failure instead of a phantom empty state.
def test_get_album_display_meta_propagates_db_errors(db):
"""If the underlying tables don't exist, the helper must raise
rather than swallow it as a missing-album result."""
# Don't seed — the schema is empty, so the SELECT will fail with
# OperationalError ("no such table: albums").
with pytest.raises(sqlite3.OperationalError):
db.get_album_display_meta('alb-1')
def test_get_artist_albums_for_reorganize_propagates_db_errors(db):
with pytest.raises(sqlite3.OperationalError):
db.get_artist_albums_for_reorganize('ar-1')

View file

@ -0,0 +1,479 @@
"""Tests for `core.reorganize_queue.ReorganizeQueue`.
Contract this test file pins:
1. **Dedupe on enqueue** re-submitting an album that's already queued or
running returns ``{'queued': False, 'reason': 'already_queued'}`` and
the existing queue_id, never a duplicate.
2. **FIFO order** the worker drains items in submission order.
3. **Per-item source preserved** the source string the user picked at
enqueue time is what the runner sees, even when multiple items with
different sources are interleaved.
4. **Continue on failure** a runner that raises (or one whose summary
reports a non-completed status) marks that item failed and the
worker moves to the next item, it does not stall.
5. **Cancel queued** items in `queued` state can be dropped before
they reach the runner.
6. **Cancel running rejected** the currently-running item can NOT be
cancelled, the API returns `running_cant_cancel`.
7. **Clear queued** bulk-cancels all `queued` items at once, leaves
the running item alone.
8. **Snapshot shape** `active`, `queued`, `recent`, and `totals` keys
are always present and reflect the current state.
9. **update_active_progress** live progress fields propagate onto the
running item (and only the running item).
10. **Setting runner late** items enqueued before `set_runner()` was
called still get processed once the runner shows up.
"""
import threading
import time
import pytest
from core.reorganize_queue import ReorganizeQueue, QueueItem
# --- helpers ---------------------------------------------------------------
def _make_runner(record, *, raise_on=None, summary_factory=None,
block_event=None, runtime=0.0):
"""Build a runner closure that records what it was called with.
Args:
record: list to append `(queue_id, source)` to per call.
raise_on: queue_id (or set of queue_ids) for which the runner
should raise used to test continue-on-failure.
summary_factory: optional callable `(item) -> summary dict` to
override the default `{'status': 'completed', ...}` shape.
block_event: optional `threading.Event` the runner blocks on
before returning used to keep an item in 'running' state
while the test pokes at it.
runtime: seconds the runner sleeps before returning.
"""
raise_set = set()
if isinstance(raise_on, str):
raise_set = {raise_on}
elif raise_on:
raise_set = set(raise_on)
def runner(item):
record.append((item.queue_id, item.source))
if block_event is not None:
block_event.wait(timeout=2.0)
if runtime:
time.sleep(runtime)
if item.queue_id in raise_set:
raise RuntimeError(f"Simulated failure for {item.queue_id}")
if summary_factory is not None:
return summary_factory(item)
return {
'status': 'completed',
'source': item.source or 'spotify',
'total': 1,
'moved': 1,
'skipped': 0,
'failed': 0,
'errors': [],
}
return runner
def _enqueue(queue, *, album_id, source=None, title=None, artist='Aerosmith'):
return queue.enqueue(
album_id=album_id,
album_title=title or f"Album {album_id}",
artist_id='artist-1',
artist_name=artist,
source=source,
)
def _wait_for(predicate, timeout=2.0, interval=0.02):
"""Poll until predicate() is truthy or timeout elapses."""
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(interval)
return False
@pytest.fixture
def queue():
q = ReorganizeQueue()
yield q
q.stop()
# --- tests -----------------------------------------------------------------
def test_enqueue_returns_queued_with_position(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
r1 = _enqueue(queue, album_id='alb-1')
# Wait for the worker to actually pick up alb-1 so r2 lands while
# alb-1 is running, not while it's still queued — otherwise the
# position number depends on thread-scheduling timing.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
r2 = _enqueue(queue, album_id='alb-2')
assert r1['queued'] is True
assert r1['position'] == 1
assert r2['queued'] is True
assert r2['position'] == 1
block.set()
def test_enqueue_same_album_dedupes(queue):
queue.set_runner(_make_runner([], block_event=threading.Event()))
r1 = _enqueue(queue, album_id='alb-1', source='spotify')
r2 = _enqueue(queue, album_id='alb-1', source='deezer') # different source
assert r1['queued'] is True
assert r2['queued'] is False
assert r2['reason'] == 'already_queued'
assert r2['queue_id'] == r1['queue_id']
def test_dedupe_releases_after_completion(queue):
"""Once an item finishes (done/failed/cancelled), the same album_id
can be re-enqueued. Otherwise users couldn't retry after a fix."""
record = []
queue.set_runner(_make_runner(record))
r1 = _enqueue(queue, album_id='alb-1')
assert _wait_for(lambda: any(r[0] == r1['queue_id'] for r in record))
# Wait for the item to flip into the recent bucket.
assert _wait_for(lambda: queue.snapshot()['active'] is None)
r2 = _enqueue(queue, album_id='alb-1')
assert r2['queued'] is True
assert r2['queue_id'] != r1['queue_id']
def test_fifo_order(queue):
record = []
queue.set_runner(_make_runner(record))
ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(5)]
assert _wait_for(lambda: len(record) == 5)
assert [r[0] for r in record] == ids
def test_per_item_source_preserved(queue):
record = []
queue.set_runner(_make_runner(record))
sources = ['spotify', 'deezer', 'itunes', None, 'discogs']
for i, src in enumerate(sources):
_enqueue(queue, album_id=f'alb-{i}', source=src)
assert _wait_for(lambda: len(record) == len(sources))
assert [r[1] for r in record] == sources
def test_continue_on_runner_exception(queue):
"""A runner that raises must not stall the queue — the item is
marked failed and the next item runs."""
record = []
# Pre-allocate queue_ids by enqueuing first, then point the runner
# at the middle one. Block the runner so all three sit in the queue
# before any actually run.
block = threading.Event()
raise_target = {}
def runner(item):
record.append((item.queue_id, item.source))
block.wait(timeout=2.0)
if item.queue_id == raise_target.get('id'):
raise RuntimeError(f"Simulated failure for {item.queue_id}")
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
queue.set_runner(runner)
ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(3)]
raise_target['id'] = ids[1]
block.set()
assert _wait_for(lambda: len(record) == 3)
assert [r[0] for r in record] == ids
assert _wait_for(lambda: queue.snapshot()['active'] is None)
snap = queue.snapshot()
recent_by_id = {r['queue_id']: r for r in snap['recent']}
assert recent_by_id[ids[0]]['status'] == 'done'
assert recent_by_id[ids[1]]['status'] == 'failed'
assert recent_by_id[ids[2]]['status'] == 'done'
def test_failed_status_when_runner_reports_failed_tracks(queue):
"""A summary with ``failed > 0`` should mark the queue item as
'failed' even if the runner returned normally."""
queue.set_runner(_make_runner([], summary_factory=lambda item: {
'status': 'completed',
'source': 'spotify',
'total': 5,
'moved': 4,
'skipped': 0,
'failed': 1,
'errors': [{'track_id': 't-1', 'title': 'X', 'error': 'boom'}],
}))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
# Wait for the item to land in `recent` (active is None both before
# the worker picks up the item and after it's done — only the
# presence in recent is unambiguous).
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
item = next(i for i in snap['recent'] if i['queue_id'] == qid)
assert item['status'] == 'failed'
assert item['moved'] == 4
assert item['failed'] == 1
assert item['error'] == 'boom'
def test_failed_status_when_runner_reports_non_completed_status(queue):
"""``status='no_source_id'`` and friends are setup-failures — they
leave failed=0 but the item is still NOT a success."""
queue.set_runner(_make_runner([], summary_factory=lambda item: {
'status': 'no_source_id',
'source': None,
'total': 0,
'moved': 0,
'skipped': 0,
'failed': 0,
'errors': [],
}))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
item = next(r for r in snap['recent'] if r['queue_id'] == qid)
assert item['status'] == 'failed'
assert item['result_status'] == 'no_source_id'
def test_cancel_queued_item(queue):
"""Cancel BEFORE the worker reaches the item drops it cleanly."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
first = _enqueue(queue, album_id='alb-1')['queue_id'] # gets pulled to running, blocks
second = _enqueue(queue, album_id='alb-2')['queue_id'] # sits in queued
# Wait for first to be running so we know the worker is parked on it.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
result = queue.cancel(second)
assert result['cancelled'] is True
snap = queue.snapshot()
assert all(i['queue_id'] != second for i in snap['queued'])
# And the cancelled one shows up in recent with status 'cancelled'.
assert any(i['queue_id'] == second and i['status'] == 'cancelled' for i in snap['recent'])
block.set() # release the running item
def test_cancel_running_rejected(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
result = queue.cancel(qid)
assert result['cancelled'] is False
assert result['reason'] == 'running_cant_cancel'
block.set()
def test_cancel_unknown_id(queue):
result = queue.cancel('does-not-exist')
assert result['cancelled'] is False
assert result['reason'] == 'not_found'
def test_clear_queued_bulk_cancel(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
_enqueue(queue, album_id='alb-1') # running, blocked
queued_ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(2, 6)]
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
assert _wait_for(lambda: len(queue.snapshot()['queued']) == 4)
cancelled = queue.clear_queued()
assert cancelled == 4
snap = queue.snapshot()
assert len(snap['queued']) == 0
# Running item is untouched.
assert snap['active'] is not None
cancelled_in_recent = [i for i in snap['recent'] if i['status'] == 'cancelled']
assert {i['queue_id'] for i in cancelled_in_recent} == set(queued_ids)
block.set()
def test_snapshot_shape(queue):
snap = queue.snapshot()
assert set(snap.keys()) == {'active', 'queued', 'recent', 'totals'}
assert set(snap['totals'].keys()) >= {'queued', 'running', 'done', 'failed', 'cancelled'}
assert snap['active'] is None
assert snap['queued'] == []
assert snap['recent'] == []
def test_update_active_progress_only_targets_running(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
queue.update_active_progress(
queue_id=qid,
current_track='Dream On',
total=8,
processed=3,
moved=3,
skipped=0,
failed=0,
)
snap = queue.snapshot()
assert snap['active']['current_track'] == 'Dream On'
assert snap['active']['progress_total'] == 8
assert snap['active']['progress_processed'] == 3
assert snap['active']['moved'] == 3
block.set()
def test_update_progress_for_unknown_id_is_noop(queue):
"""Calling update_active_progress for an item that isn't running
must not raise, must not corrupt other items."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
queue.update_active_progress(queue_id='not-a-real-id', current_track='X', total=999)
snap = queue.snapshot()
assert snap['active']['queue_id'] == qid
assert snap['active']['progress_total'] == 0 # unchanged
block.set()
def test_enqueue_many_tallies_enqueued_and_dedupes(queue):
"""Bulk enqueue returns ``{enqueued, already_queued, total}`` so
the route handler doesn't have to count itself. Re-enqueuing the
same album-id twice in the same batch dedupes."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
# Pre-existing item — should appear as already_queued.
queue.enqueue(album_id='alb-existing', album_title='X',
artist_id='ar-1', artist_name='A', source=None)
# Wait for it to be running so the dedupe path triggers.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
items = [
{'album_id': 'alb-existing', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-new-1', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-new-2', 'album_title': 'Z', 'artist_id': 'ar-1', 'artist_name': 'A'},
]
result = queue.enqueue_many(items)
assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3}
block.set()
def test_enqueue_many_carries_source_per_item(queue):
"""Each dict's ``source`` is honoured independently — the bulk
helper doesn't collapse them to one value."""
record = []
queue.set_runner(_make_runner(record))
items = [
{'album_id': 'a', 'album_title': 'A', 'artist_id': 'x', 'artist_name': 'X', 'source': 'spotify'},
{'album_id': 'b', 'album_title': 'B', 'artist_id': 'x', 'artist_name': 'X', 'source': 'deezer'},
{'album_id': 'c', 'album_title': 'C', 'artist_id': 'x', 'artist_name': 'X', 'source': None},
]
queue.enqueue_many(items)
assert _wait_for(lambda: len(record) == 3)
assert [r[1] for r in record] == ['spotify', 'deezer', None]
def test_enqueue_many_handles_empty_list(queue):
queue.set_runner(_make_runner([]))
assert queue.enqueue_many([]) == {'enqueued': 0, 'already_queued': 0, 'total': 0}
def test_enqueue_many_dedupes_batch_internal_duplicates(queue):
"""Same album_id appearing twice in the same bulk request must be
deduped against each other not just against pre-existing items.
Regression for the race where a fast runner finishes the first copy
before the loop reaches the second, letting both slip through."""
record = []
queue.set_runner(_make_runner(record))
items = [
{'album_id': 'alb-x', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-y', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-x', 'album_title': 'X (dup)', 'artist_id': 'ar-1', 'artist_name': 'A'},
]
result = queue.enqueue_many(items)
assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3}
# Wait for the queue to drain, then give the worker a moment to
# try (and fail) to pick a phantom third item. If the dedupe leaked,
# a third runner call would land here.
assert _wait_for(lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'])
time.sleep(0.05)
assert len(record) == 2
def test_cancel_and_run_are_mutually_exclusive(queue):
"""Regression for kettui's ``_next_queued() → status flip`` race:
a successfully-cancelled item must NEVER have its runner invoked.
With the old non-atomic pick + flip, cancel could land between
the worker's pick and its flip-to-running, leaving the item
marked 'cancelled' but the worker still runs it.
Hammers many enqueue-then-immediately-cancel pairs to exercise the
race window. After draining, every queue_id whose cancel returned
``cancelled: True`` must NOT appear in the runner's record."""
runner_called: set = set()
runner_lock = threading.Lock()
def runner(item):
with runner_lock:
runner_called.add(item.queue_id)
# Slight runtime widens the window where overlapping cancels
# could (incorrectly) fire on a running item.
time.sleep(0.002)
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
queue.set_runner(runner)
successful_cancels: set = set()
for i in range(50):
r = _enqueue(queue, album_id=f'alb-race-{i}')
# Immediately try to cancel — half will land while item is still
# 'queued', half will land after worker has flipped to 'running'.
if queue.cancel(r['queue_id'])['cancelled']:
successful_cancels.add(r['queue_id'])
assert _wait_for(
lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'],
timeout=5.0,
)
leaked = successful_cancels & runner_called
assert not leaked, f"Runner ran for cancelled items: {leaked}"
def test_no_runner_marks_item_failed(queue):
"""If the worker pulls an item but no runner has been set, the item
must be marked failed (not silently dropped). In practice
web_server.py wires the runner at module load before any request
can land, so this is a defensive-failure path more than a real
one but the failure mode must be loud."""
queue.set_runner(None)
qid = _enqueue(queue, album_id='alb-orphan')['queue_id']
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
failed = next(i for i in snap['recent'] if i['queue_id'] == qid)
assert failed['status'] == 'failed'
assert 'runner' in (failed['error'] or '').lower()

View file

@ -0,0 +1,235 @@
"""Tests for `core.reorganize_runner.build_runner`.
Contract this test file pins:
1. **Runner is a closure** calling `build_runner` returns a callable
that takes a queue item and returns a summary dict matching
`reorganize_album`'s shape.
2. **Config is read per-run, not at factory time** changing the
download/transfer path between runs is honoured. Web server config
should never need a restart for this to take effect.
3. **Setup failure surfaces a clean summary** if the staging dir
cannot be created, the runner returns `status='setup_failed'`
instead of raising (so the queue marks the item failed cleanly).
4. **Progress callbacks fan out into the queue** the runner wires
`reorganize_album`'s `on_progress` to `update_active_progress` on
the live singleton queue, so the status panel sees per-track state.
5. **Dependencies are injected, not imported** the factory takes
every external dependency as a callable so tests can run without
spinning up Flask, the DB, or the post-process pipeline.
"""
import sys
import types
from unittest.mock import MagicMock
import pytest
# Stub config.settings so importing core.reorganize_runner -> core.library_reorganize doesn't blow up
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
from core.reorganize_runner import build_runner # noqa: E402
@pytest.fixture(autouse=True)
def reset_queue_singleton():
"""Each test gets a fresh queue singleton so update_active_progress
in one test doesn't leak into another."""
from core.reorganize_queue import reset_queue_for_tests
reset_queue_for_tests()
yield
reset_queue_for_tests()
def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None):
"""Mock queue item — only needs the fields the runner reads."""
item = MagicMock()
item.queue_id = queue_id
item.album_id = album_id
item.source = source
return item
def _build(monkeypatch, *, download_path_fn, transfer_path_fn,
reorganize_album_fn, get_database=lambda: object()):
"""Helper: stub out the heavy reorganize_album call so we can test
the wiring without a real DB / post-process pipeline."""
# Patch the import inside reorganize_runner.build_runner.
import core.reorganize_runner as mod
monkeypatch.setattr(
'core.library_reorganize.reorganize_album',
reorganize_album_fn,
raising=True,
)
return build_runner(
get_database=get_database,
resolve_file_path_fn=lambda p: p,
post_process_fn=lambda *a, **k: None,
cleanup_empty_directories_fn=lambda *a, **k: None,
is_shutting_down_fn=lambda: False,
get_download_path=download_path_fn,
get_transfer_path=transfer_path_fn,
)
def test_runner_invokes_reorganize_album_with_injected_deps(monkeypatch, tmp_path):
captured = {}
def fake_reorganize_album(**kwargs):
captured.update(kwargs)
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
runner = _build(
monkeypatch,
download_path_fn=lambda: str(tmp_path),
transfer_path_fn=lambda: str(tmp_path / 'transfer'),
reorganize_album_fn=fake_reorganize_album,
)
item = _make_item(album_id='alb-X', source='deezer')
summary = runner(item)
assert summary['status'] == 'completed'
assert captured['album_id'] == 'alb-X'
assert captured['primary_source'] == 'deezer'
assert captured['strict_source'] is True
# staging_root is download_path / ssync_staging
assert captured['staging_root'].endswith('ssync_staging')
assert callable(captured['on_progress'])
assert callable(captured['stop_check'])
def test_runner_reads_config_per_call(monkeypatch, tmp_path):
"""Path that the runner sees should reflect the value returned by
the path-resolver lambda AT call time not at build_runner time.
This is the explicit fix for kettui-style "config change requires
server restart" feedback."""
seen_staging_roots = []
def fake_reorganize_album(**kwargs):
seen_staging_roots.append(kwargs['staging_root'])
return {
'status': 'completed', 'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, 'errors': [],
}
current_path = {'value': str(tmp_path / 'first')}
runner = _build(
monkeypatch,
download_path_fn=lambda: current_path['value'],
transfer_path_fn=lambda: '/tmp/transfer',
reorganize_album_fn=fake_reorganize_album,
)
runner(_make_item())
current_path['value'] = str(tmp_path / 'second')
runner(_make_item())
assert len(seen_staging_roots) == 2
assert 'first' in seen_staging_roots[0]
assert 'second' in seen_staging_roots[1]
def test_runner_returns_setup_failed_on_unwritable_path(monkeypatch, tmp_path):
"""If the staging dir can't be created (permission denied, etc.),
the runner returns a clean ``setup_failed`` summary so the queue
marks the item failed without an unhandled exception."""
def fake_reorganize_album(**kwargs):
pytest.fail("reorganize_album should not run when setup fails")
# Point at a child of an existing FILE — makedirs will raise OSError.
blocking_file = tmp_path / 'blocker'
blocking_file.write_text('x')
runner = _build(
monkeypatch,
download_path_fn=lambda: str(blocking_file), # makedirs fails here
transfer_path_fn=lambda: '/tmp/transfer',
reorganize_album_fn=fake_reorganize_album,
)
summary = runner(_make_item())
assert summary['status'] == 'setup_failed'
assert summary['errors']
def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path):
"""When reorganize_album fires its on_progress callback, the runner
must forward into the live queue's update_active_progress so the
status panel sees per-track updates."""
from core.reorganize_queue import get_queue, ReorganizeQueue
import threading
# Use a real queue that's blocked on a runner — gives us a known
# 'running' item to propagate progress into.
block = threading.Event()
def fake_reorganize_album(*, on_progress, **kwargs):
# Simulate per-track progress emissions like the real
# orchestrator does.
on_progress({'current_track': 'Backseat Freestyle', 'total': 12, 'processed': 1})
on_progress({'moved': 1, 'processed': 1})
return {
'status': 'completed', 'source': 'spotify',
'total': 12, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
runner = _build(
monkeypatch,
download_path_fn=lambda: str(tmp_path),
transfer_path_fn=lambda: str(tmp_path / 'transfer'),
reorganize_album_fn=fake_reorganize_album,
)
# Wire our runner into the singleton queue and enqueue an item, so
# update_active_progress has a 'running' item to write into.
q = get_queue()
q.set_runner(runner)
enq = q.enqueue(album_id='alb-1', album_title='good kid',
artist_id='ar-1', artist_name='Kendrick Lamar', source=None)
# Wait for the worker to finish (fake_reorganize_album is fast).
deadline_passes = 0
import time
while deadline_passes < 50:
snap = q.snapshot()
if any(r['queue_id'] == enq['queue_id'] for r in snap['recent']):
break
time.sleep(0.02)
deadline_passes += 1
snap = q.snapshot()
finished = next(r for r in snap['recent'] if r['queue_id'] == enq['queue_id'])
assert finished['status'] == 'done'
assert finished['moved'] == 1
# The progress fan-out happened *while* the item was running. The
# final snapshot shows the worker-set values — what we're really
# asserting is that progress callbacks didn't raise.

View file

@ -169,6 +169,28 @@ app = Flask(
)
app.config['TEMPLATES_AUTO_RELOAD'] = DEV_STATIC_NO_CACHE
app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE
# Force static assets (library.js / style.css / etc.) to revalidate
# with ETag on every load instead of Flask's default 12-hour browser
# cache. Updates ship live without users having to clear cache.
# Modern browsers still serve 304 Not Modified when the file hasn't
# changed, so the cost per asset per reload is just a header round-trip.
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0
# Cache-bust query string for static assets — appended to every
# url_for('static', ...) URL via the context processor below. Computed
# once per process start so each server restart invalidates the
# browser's cached copy of every JS/CSS file. This is the surefire
# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
# itself changes, so the browser cannot reuse a previously-cached
# response no matter what its Cache-Control header said.
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
@app.context_processor
def _inject_static_cache_bust():
return {'static_v': _STATIC_CACHE_BUST}
# --- Flask Session Setup (for multi-profile support) ---
import secrets as _secrets
@ -13592,19 +13614,12 @@ def get_tracks_replaygain_batch_status():
return jsonify(state)
# ── Reorganize Album Files endpoint ──
_reorganize_state = {
'status': 'idle',
'total': 0,
'processed': 0,
'moved': 0,
'skipped': 0,
'failed': 0,
'current_track': '',
'errors': [],
}
_reorganize_lock = threading.Lock()
# ── Reorganize Album Files endpoints ──
#
# Reorganize requests flow through ``core.reorganize_queue`` — a FIFO
# queue with a single background worker. The endpoints here are thin
# enqueue / snapshot / cancel wrappers; the heavy lifting is in
# :mod:`core.library_reorganize`.
@app.route('/api/library/reorganize/sources', methods=['GET'])
@ -13672,113 +13687,143 @@ def reorganize_album_preview(album_id):
@app.route('/api/library/album/<album_id>/reorganize', methods=['POST'])
def reorganize_album_files(album_id):
"""Re-route an album's existing files through the same post-processing
pipeline downloads use. Implementation lives in
:mod:`core.library_reorganize` to keep this monolith from growing.
The request body's ``template`` (if any) is ignored — post-processing
always uses the configured template, matching the download path.
"""Enqueue an album for reorganize. Returns immediately — the
queue worker processes items FIFO. Repeat clicks for an album
that's already queued or running are deduped (returns
``{queued: false, reason: 'already_queued'}``).
Optional body param ``source``: when provided, only that metadata
source is used (no fallback chain). Matches the per-album source
picker in the reorganize modal."""
Body params:
source (optional): per-album source pick (Spotify / iTunes /
Deezer / Discogs / Hydrabase). When omitted, the
orchestrator uses the configured primary with fallback.
"""
try:
from core.reorganize_queue import get_queue
data = request.get_json() or {}
chosen_source = data.get('source') or None
with _reorganize_lock:
if _reorganize_state['status'] == 'running':
return jsonify({"success": False, "error": "A reorganization is already in progress"}), 409
_reorganize_state['status'] = 'running'
_reorganize_state.update({
'total': 0, 'processed': 0, 'moved': 0, 'skipped': 0,
'failed': 0, 'current_track': '', 'errors': [],
# Set after the run from the orchestrator's summary so the
# frontend can distinguish 'completed' from 'no_source_id'
# / 'no_album' / 'no_tracks' / 'setup_failed' (otherwise
# zero-failure skips look green to the user).
'result_status': None, 'result_source': None,
})
download_dir = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
staging_root = os.path.join(download_dir, 'ssync_staging')
try:
os.makedirs(staging_root, exist_ok=True)
except OSError:
pass
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
def _on_progress(updates):
with _reorganize_lock:
_reorganize_state.update(updates)
def _update_track_path(track_id, new_path):
try:
_db = get_database()
with _db._get_connection() as _conn:
_conn.execute(
"UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_path, str(track_id)),
)
_conn.commit()
except Exception as _db_err:
logger.warning(f"[Reorganize] DB path update failed for {track_id}: {_db_err}")
def _cleanup_empty(src_dir):
try:
_cleanup_empty_directories(transfer_dir, os.path.join(src_dir, '_'))
except Exception:
pass
def _run():
from core.library_reorganize import reorganize_album
try:
summary = reorganize_album(
album_id=album_id,
db=get_database(),
staging_root=staging_root,
resolve_file_path_fn=_resolve_library_file_path,
post_process_fn=_post_process_matched_download,
update_track_path_fn=_update_track_path,
cleanup_empty_dir_fn=_cleanup_empty,
transfer_dir=transfer_dir,
on_progress=_on_progress,
primary_source=chosen_source,
strict_source=bool(chosen_source),
stop_check=lambda: bool(IS_SHUTTING_DOWN),
)
logger.info(
f"[Reorganize] Album {album_id} {summary['status']} "
f"(source={summary.get('source')}, moved={summary['moved']}, "
f"skipped={summary['skipped']}, failed={summary['failed']})"
)
with _reorganize_lock:
_reorganize_state['result_status'] = summary.get('status')
_reorganize_state['result_source'] = summary.get('source')
except Exception as run_err:
logger.error(f"[Reorganize] Background error: {run_err}", exc_info=True)
with _reorganize_lock:
_reorganize_state['result_status'] = 'error'
finally:
with _reorganize_lock:
_reorganize_state['status'] = 'done'
_reorganize_state['current_track'] = ''
threading.Thread(target=_run, daemon=True, name="ReorganizeAlbum").start()
return jsonify({"success": True, "message": "Reorganization started"})
# Capture display fields at enqueue time so the status panel
# can render them without a DB lookup later.
meta = get_database().get_album_display_meta(album_id)
if meta is None:
return jsonify({"success": False, "error": "Album not found"}), 404
result = get_queue().enqueue(
album_id=str(album_id),
album_title=meta['album_title'],
artist_id=meta['artist_id'],
artist_name=meta['artist_name'],
source=chosen_source,
)
return jsonify({"success": True, **result})
except Exception as e:
logger.error(f"Reorganize error: {e}")
with _reorganize_lock:
_reorganize_state['status'] = 'idle'
logger.error(f"Reorganize enqueue error: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/album/reorganize/status', methods=['GET'])
def get_reorganize_status():
"""Poll the status of a running reorganization."""
with _reorganize_lock:
state = dict(_reorganize_state)
state['errors'] = list(_reorganize_state['errors'])
return jsonify(state)
@app.route('/api/library/artist/<artist_id>/reorganize-all', methods=['POST'])
def reorganize_all_artist_albums(artist_id):
"""Enqueue every album for an artist. Replaces the old frontend
bulk-loop. Each album becomes its own queue item, processed FIFO.
Albums already queued or running are deduped silently.
Body params:
source (optional): same pick applied to every album. Per-album
overrides aren't supported here — use the per-album modal
for that.
"""
try:
from core.reorganize_queue import get_queue
data = request.get_json() or {}
chosen_source = data.get('source') or None
albums = get_database().get_artist_albums_for_reorganize(artist_id)
if not albums:
return jsonify({"success": False, "error": "No albums found for this artist"}), 404
# Apply the user's chosen source to every album, then hand off
# to the queue's bulk-enqueue helper which owns the loop+tally.
for album in albums:
album['source'] = chosen_source
result = get_queue().enqueue_many(albums)
return jsonify({
"success": True,
"enqueued": result['enqueued'],
"already_queued": result['already_queued'],
"total_albums": result['total'],
})
except Exception as e:
logger.error(f"Reorganize-all enqueue error: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/reorganize/queue', methods=['GET'])
def reorganize_queue_snapshot():
"""Snapshot of the reorganize queue — what's running, what's queued,
recent completions. Polled by the status panel."""
try:
from core.reorganize_queue import get_queue
return jsonify({"success": True, **get_queue().snapshot()})
except Exception as e:
logger.error(f"Reorganize queue snapshot error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/reorganize/queue/<queue_id>/cancel', methods=['POST'])
def reorganize_queue_cancel(queue_id):
"""Cancel a queued item (running items can't be cleanly cancelled —
see the queue module's design rules)."""
try:
from core.reorganize_queue import get_queue
result = get_queue().cancel(queue_id)
status_code = 200 if result.get('cancelled') else 409
return jsonify({"success": result.get('cancelled', False), **result}), status_code
except Exception as e:
logger.error(f"Reorganize cancel error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/reorganize/queue/clear', methods=['POST'])
def reorganize_queue_clear():
"""Cancel all queued items at once (the running item continues)."""
try:
from core.reorganize_queue import get_queue
cancelled = get_queue().clear_queued()
return jsonify({"success": True, "cancelled": cancelled})
except Exception as e:
logger.error(f"Reorganize clear error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
# Wire the reorganize queue worker to its runner at module load. The
# runner factory lives in :mod:`core.reorganize_runner` so this monolith
# stays small. Config (paths) is read **per run** inside the closure,
# so changing your download path in Settings takes effect on the next
# reorganize without a server restart.
#
# The injected callables are wrapped in lambdas because the underlying
# helpers (``_resolve_library_file_path`` etc.) are defined LATER in
# this file. Lambdas defer name resolution to call time so module-load
# import order works regardless of definition order.
try:
from core.reorganize_queue import get_queue as _get_reorganize_queue
from core.reorganize_runner import build_runner as _build_reorganize_runner
_get_reorganize_queue().set_runner(_build_reorganize_runner(
get_database=get_database,
resolve_file_path_fn=lambda p: _resolve_library_file_path(p),
post_process_fn=lambda *a, **kw: _post_process_matched_download(*a, **kw),
cleanup_empty_directories_fn=lambda *a, **kw: _cleanup_empty_directories(*a, **kw),
is_shutting_down_fn=lambda: bool(IS_SHUTTING_DOWN),
get_download_path=lambda: docker_resolve_path(
config_manager.get('soulseek.download_path', './downloads')
),
get_transfer_path=lambda: docker_resolve_path(
config_manager.get('soulseek.transfer_path', './Transfer')
),
))
except Exception as _runner_init_err:
logger.error(f"Failed to register reorganize queue runner: {_runner_init_err}")
# ── Library Issues endpoints ──
@ -22732,6 +22777,33 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
"sections": [
{
"title": "Reorganize Queue: Race-Condition Hardening (kettui Review)",
"description": "Three concurrency / dedupe issues kettui caught in his review of PR #377, plus two related polish items from the same pass.",
"features": [
"• Worker pick + status flip is now atomic — fixes a window where a cancel() landing between 'pick next queued' and 'flip to running' could mark an item cancelled but the worker still ran it",
"• Replaced the lock + wakeup-event pair with a single threading.Condition so newly-queued items can't sleep up to 60s waiting for the next wakeup tick (the old pair had an empty-check / clear-event race)",
"• enqueue_many now holds the queue lock for the whole batch and tracks a per-batch seen set, so duplicate album_ids inside one bulk call are deduped against each other (not just against pre-existing items)",
"• Reorganize-preview Apply button no longer gets stuck disabled when an early return / network error skipped the re-enable line — moved into a finally",
"• DB helpers get_album_display_meta and get_artist_albums_for_reorganize now let exceptions bubble instead of swallowing them as 'not found' / empty list — a real DB outage now surfaces as a 500 to the user instead of looking like a missing album",
],
},
{
"title": "Reorganize Queue with Live Status Panel",
"description": "Reorganizing albums is no longer a foreground operation that locks the page. Click → enqueue → keep working. A status panel surfaces live progress.",
"features": [
"• Per-album Reorganize and Reorganize All both enqueue into a single FIFO queue with a backend worker that drains one item at a time",
"• Buttons stay clickable — spam-clicking the same album silently dedupes (returns 'already queued' instead of 409-ing)",
"• Status panel at the top of the artist actions bar shows: active item (progress bar, current track, moved/skipped/failed counts), queued count, and recently-finished items with success/warning indicators",
"• Click the panel to expand: full queue list with per-item cancel buttons; running item can't be cancelled mid-flight (Python threads aren't cleanly killable, post-process spawns subprocesses)",
"'Cancel All' button drops every queued item at once — the running one continues",
"• Items belonging to a different artist than the page you're on are flagged with the artist name so cross-artist progress is obvious",
"• Each queued item carries its own metadata source pick (Spotify / iTunes / Deezer / Discogs / Hydrabase) — switching modal selections per album works",
"'Reorganize All' is now one backend call instead of N JS-driven calls — the loop runs server-side and is much faster",
"• Continue-on-failure: a single failed album never stalls the queue; the worker logs and moves on",
"• Retired the old single-slot reorganize state endpoint plus the polling loops that depended on it",
],
},
{
"title": "Fix Wrong-Artist Tracks Silently Downloading",
"description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name",

View file

@ -5,10 +5,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<title>SoulSync - Music Sync & Manager</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css') }}">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
</head>
<body>
@ -7860,27 +7860,27 @@
</div>
</div>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
<script src="{{ url_for('static', filename='core.js') }}"></script>
<script src="{{ url_for('static', filename='shared-helpers.js') }}"></script>
<script src="{{ url_for('static', filename='media-player.js') }}"></script>
<script src="{{ url_for('static', filename='settings.js') }}"></script>
<script src="{{ url_for('static', filename='search.js') }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js') }}"></script>
<script src="{{ url_for('static', filename='downloads.js') }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js') }}"></script>
<script src="{{ url_for('static', filename='sync-services.js') }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js') }}"></script>
<script src="{{ url_for('static', filename='library.js') }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js') }}"></script>
<script src="{{ url_for('static', filename='discover.js') }}"></script>
<script src="{{ url_for('static', filename='enrichment.js') }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js') }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js') }}"></script>
<script src="{{ url_for('static', filename='init.js') }}"></script>
<script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shared-helpers.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='media-player.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='settings.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Ambient glow under the global search bar. Radial gradient, brightest
directly under the bar, tapering out toward the window corners.
@ -7963,10 +7963,10 @@
<span>?</span>
</button>
<script src="{{ url_for('static', filename='docs.js') }}"></script>
<script src="{{ url_for('static', filename='helper.js') }}"></script>
<script src="{{ url_for('static', filename='particles.js') }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js') }}"></script>
<script src="{{ url_for('static', filename='docs.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='helper.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='particles.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
</body>
</html>

View file

@ -3443,6 +3443,8 @@ const WHATS_NEW = {
'2.40': [
// --- Search & Artists unification (in progress, not yet released) ---
{ date: 'Unreleased — Search & Artists unification', unreleased: true },
{ title: 'Reorganize Queue: Race-Condition Hardening (kettui Review)', desc: 'kettui\'s review of PR #377 caught two real concurrency bugs in the new reorganize queue and one input-deduplication gap. (1) Worker race: the worker thread looked up the next queued item, then released the lock, then re-acquired it to flip status to "running". A cancel() landing in that window would mark the item cancelled but the worker still ran it. Now picks and flips atomically under a single lock acquisition. (2) Wakeup race: the worker cleared its wakeup event after observing an empty queue, but enqueue could fire its wakeup.set() between the empty check and the clear, making a freshly-queued album sleep up to 60 seconds before the worker noticed. Replaced the lock + event pair with a single threading.Condition so check-and-wait happen under the same lock atomically. (3) Bulk-enqueue dedupe: enqueue_many called single-item enqueue in a loop, so two copies of the same album_id in one bulk request could both slip through if the worker finished the first copy before the loop reached the second. Now holds the queue lock for the entire batch and tracks a per-batch seen set, so intra-batch duplicates are deduped against each other, not just against pre-existing items. Also fixed two related issues from the same review: the reorganize-preview Apply button could get stuck disabled when an early return / network error skipped the re-enable line (moved into a finally), and the new DB helpers (get_album_display_meta, get_artist_albums_for_reorganize) used to swallow every exception and return None / [], which made a real DB outage look like "album not found" — they now let exceptions bubble so the route layer surfaces a proper 500', page: 'library', unreleased: true },
{ title: 'Reorganize Queue with Live Status Panel', desc: 'Reorganizing albums no longer locks up the page or runs as a JS-driven loop. Each click on the per-album reorganize button — or "Reorganize All" — now enqueues into a single FIFO queue that a backend worker drains one item at a time. Buttons stay clickable: spam-clicking the same album silently dedupes, and you can keep browsing while items run. A status panel mounted at the top of the artist actions bar shows what\'s active (with a progress bar, current track, and live moved/skipped/failed counts), how many items are queued behind it, and recently-finished items with success/warning indicators. The panel expands to show the full queue with per-item cancel buttons (running items can\'t be cancelled mid-flight; queued ones can) and a "Cancel All" button for the queued tail. Items belonging to a different artist than the page you\'re on are flagged with a "(other artist)" hint so you understand what you\'re seeing. Bonus: "Reorganize All" is now one backend call instead of N JS-driven calls — much faster, and the artist context is captured server-side per item so the queue can show cross-artist progress correctly. Also retired the old single-slot status endpoint and the polling loop that depended on it', page: 'library', unreleased: true },
{ title: 'Fix Album Completeness Job Reporting Zero Findings for Everyone', desc: 'sassmastawillis reported the Album Completeness maintenance job was finishing in 0.1s with 0 findings, even for users with obviously-incomplete albums. Root cause: the job used `albums.track_count` as the "expected total" to compare against the library\'s actual count. But `track_count` is populated by server syncs (Plex leafCount, SoulSync standalone len(tracks)) — it\'s always the OBSERVED count, never what the metadata provider says the album should contain. So expected == actual always, and every album looked complete. Fix: new `api_track_count` column on the albums table, written only by metadata-source code paths (Spotify, iTunes, Deezer, and Discogs enrichment workers now populate it whenever they fetch album data, so it piggybacks on existing API calls instead of making new ones). Server syncs never touch this column, so it stays authoritative. The repair job uses it as the expected total; if an album somehow hasn\'t been enriched yet, the job falls back to a live API lookup and caches the result. For users with an already-enriched library, the first completeness scan after the upgrade is fast because the workers will have populated the column during normal enrichment cycles', page: 'library', unreleased: true },
{ title: 'Library Reorganize: Reroute Through the Download Pipeline', desc: 'Reported by winecountrygames — using "Reorganize All" on a 3-disc Aerosmith deluxe collapsed it to a flat 1-disc layout, and on other albums it left half the tracks in their original location with no error or count of what was skipped. Root cause: the reorganize endpoint reinvented several wheels (its own template engine, its own disc-number resolution from file tags, its own sidecar sweep, its own collision detection) and each had drifted from the canonical post-processing path used by downloads. The reorganize-only logic read disc_number from file tags and silently defaulted to 1 on any failure, so a single tag-less file collapsed the whole album to single-disc. Tracks whose file paths didn\'t resolve on disk were silently skipped. Rewrote it to follow the import page\'s pattern: copy each file to a per-album staging folder under your download path, look up the canonical tracklist from your configured metadata source (Deezer / Spotify / iTunes / Discogs / Hydrabase) using the album\'s stored source IDs, then route each file through the same `_post_process_matched_download` function fresh downloads use — same template, same tagging, same multi-disc subfolder logic, same sidecar handling, same AcoustID verification. Albums with no stored source ID are reported back and skipped entirely (degrading silently to file tags is what caused the original bug). Tracks not in the source\'s catalog version (bonus tracks on a deluxe edition) are reported as skipped and left in place rather than force-fed wrong context. Files that don\'t resolve on disk are surfaced with the offending DB path so the UI can show them. The 230-line inline reorganize logic in web_server.py was extracted into core/library_reorganize.py — net -195 lines from the monolith, +13 unit tests for the new orchestrator. Frontend behavior change: the per-call template parameter in the reorganize modal is now ignored — reorganize uses your configured download template, matching the pipeline downloads use', page: 'library', unreleased: true },
{ title: 'Spotify: Longer Post-Ban Cooldown (30 min)', desc: 'A user reported their Spotify rate-limit ban expired after 4 hours, the system ran its 5-minute post-ban cooldown, and then 32 seconds after the cooldown ended a single get_artist_albums call from a background worker was hit with another 4-hour ban. Diagnosis: Spotify\'s server-side memory of the previous offense outlasted our 5-minute cooldown, so the very first call after cooldown got slapped immediately. The cooldown exists specifically to prevent the "ban expires → we probe → re-ban" cycle, but the value was too short. Bumped from 5 minutes to 30 minutes — same mechanism, just enough room for Spotify to actually forget. A more principled follow-up (adaptive cooldown that scales with the previous ban size, plus making the first post-cooldown call a single light probe rather than allowing background workers through) is documented as a future PR if reports persist after this bump', page: 'dashboard', unreleased: true },

View file

@ -2710,6 +2710,10 @@ function renderArtistMetaPanel(artist) {
const headerRight = document.createElement('div');
headerRight.className = 'enhanced-artist-meta-actions';
// Live reorganize-queue status — sits first so the user sees what's
// happening before any of the action buttons.
mountReorganizeStatusPanel(headerRight, String(artist.id));
if (isEnhancedAdmin()) {
const editToggle = document.createElement('button');
editToggle.className = 'enhanced-meta-edit-toggle';
@ -3272,6 +3276,7 @@ function renderExpandedAlbumHeader(album) {
reorganizeBtn.className = 'enhanced-reorganize-album-btn';
reorganizeBtn.innerHTML = '&#128193; Reorganize';
reorganizeBtn.title = 'Reorganize album files using your configured download template';
reorganizeBtn.dataset.albumId = String(album.id);
reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); };
enrichRow.appendChild(reorganizeBtn);
@ -6104,11 +6109,27 @@ function _pollBatchRgStatus() {
}
// ── Reorganize Album Files ──
//
// Click → enqueue → close modal. The reorganize queue worker (server-
// side) processes items FIFO. The Reorganize Status panel mounted at
// the top of the artist's enhanced-actions section is what surfaces
// live progress — buttons no longer wait or lock.
let _reorganizeAlbumId = null;
let _reorganizePollTimer = null;
async function showReorganizeModal(albumId) {
// Short-circuit if this album is already queued or running — opening
// the modal would be misleading (the apply click would just dedupe).
const queuedState = _reorganizeStateForAlbum(albumId);
if (queuedState) {
const label = queuedState === 'running' ? 'Reorganize already running for this album' : 'Album already queued for reorganize';
showToast(label, 'info');
if (typeof refreshReorganizeStatusPanel === 'function') {
refreshReorganizeStatusPanel();
}
return;
}
_reorganizeAlbumId = albumId;
const overlay = document.getElementById('reorganize-overlay');
const body = document.getElementById('reorganize-modal-body');
@ -6203,6 +6224,13 @@ async function loadReorganizePreview() {
if (applyBtn) applyBtn.disabled = true;
previewBody.innerHTML = '<div class="reorganize-preview-loading">Loading preview...</div>';
// Final apply-button state: only enable when the preview actually
// produced movable tracks AND no collisions blocked it. Any error
// path or empty result keeps it disabled. We compute it as we go and
// commit it in finally so an early return / throw can't leave the
// button stuck disabled forever.
let canApply = false;
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, {
@ -6282,11 +6310,12 @@ async function loadReorganizePreview() {
previewBody.innerHTML = summary + html;
// Block apply if collisions exist
if (applyBtn) applyBtn.disabled = !hasChanges || hasCollisions;
canApply = hasChanges && !hasCollisions;
} catch (error) {
previewBody.innerHTML = `<div class="reorganize-preview-error">Error: ${escapeHtml(error.message)}</div>`;
} finally {
if (applyBtn) applyBtn.disabled = !canApply;
}
}
@ -6296,9 +6325,12 @@ async function executeReorganize() {
const applyBtn = document.getElementById('reorganize-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = 'Reorganizing...';
applyBtn.textContent = 'Queueing...';
}
const albumTitle = document.getElementById('reorganize-modal-title')?.textContent
?.replace(/^Reorganize:\s*/, '') || 'album';
try {
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, {
@ -6310,12 +6342,21 @@ async function executeReorganize() {
if (!result.success) throw new Error(result.error);
closeReorganizeModal();
// /reorganize no longer returns `total` (track count is determined
// server-side after planning runs); use the message from the
// backend instead. kettui PR #377 review.
showToast(result.message || 'Reorganization started', 'info');
_pollReorganizeStatus();
if (result.queued) {
const posLabel = result.position && result.position > 1 ? ` (#${result.position} in queue)` : '';
showToast(`Queued: ${albumTitle}${posLabel}`, 'info');
} else if (result.reason === 'already_queued') {
showToast(`Already queued: ${albumTitle}`, 'info');
} else {
showToast('Reorganize queued', 'info');
}
// Wake the status panel so the user sees the new item land
// immediately rather than waiting for the next poll tick.
if (typeof refreshReorganizeStatusPanel === 'function') {
refreshReorganizeStatusPanel();
}
} catch (error) {
showToast(`Reorganize failed: ${error.message}`, 'error');
if (applyBtn) {
@ -6361,40 +6402,8 @@ function _formatReorganizeResultMessage(state) {
return msg;
}
function _pollReorganizeStatus() {
if (_reorganizePollTimer) clearTimeout(_reorganizePollTimer);
async function poll() {
try {
const response = await fetch('/api/library/album/reorganize/status');
const state = await response.json();
if (state.status === 'running') {
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
showToast(`Reorganizing: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info');
_reorganizePollTimer = setTimeout(poll, 800);
} else if (state.status === 'done') {
showToast(_formatReorganizeResultMessage(state), _classifyReorganizeOutcome(state));
_reorganizePollTimer = null;
// Refresh the enhanced view to show updated paths
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
}
} catch (error) {
console.error('Poll reorganize status failed:', error);
_reorganizePollTimer = null;
}
}
_reorganizePollTimer = setTimeout(poll, 600);
}
// ── Reorganize All Albums for Artist ──
let _reorganizeAllRunning = false;
async function _showReorganizeAllModal() {
if (!artistDetailPageState.enhancedData) {
showToast('No album data loaded', 'error');
@ -6474,105 +6483,516 @@ async function _showReorganizeAllModal() {
}
async function _executeReorganizeAll() {
if (_reorganizeAllRunning) return;
const albums = artistDetailPageState.enhancedData.albums || [];
const albums = artistDetailPageState.enhancedData?.albums || [];
const total = albums.length;
const artistName = artistDetailPageState.enhancedData.artist?.name || 'this artist';
const artistName = artistDetailPageState.enhancedData?.artist?.name || 'this artist';
const artistId = artistDetailPageState.currentArtistId;
if (!artistId) return;
const confirmed = await showConfirmDialog({
title: 'Reorganize All Albums',
message: `This will reorganize ${total} album${total !== 1 ? 's' : ''} for ${artistName} using your configured download template. Files will be moved and renamed. This cannot be undone.`,
confirmText: 'Reorganize All',
message: `This will queue ${total} album${total !== 1 ? 's' : ''} for ${artistName} using your configured download template. Files will be moved and renamed. This cannot be undone.`,
confirmText: 'Queue All',
destructive: false,
});
if (!confirmed) return;
_reorganizeAllRunning = true;
const applyBtn = document.getElementById('reorganize-apply-btn');
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Working...'; }
if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Queueing...'; }
// Close modal
const overlay = document.getElementById('reorganize-overlay');
if (overlay) overlay.classList.add('hidden');
// Source picker is captured ONCE before the loop — same source for every album
// One source pick applies to every album in the batch.
const chosenSource = document.getElementById('reorganize-source-select')?.value || '';
let succeeded = 0, skipped = 0, failed = 0;
try {
const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource }),
});
const result = await resp.json();
if (!result.success) throw new Error(result.error || 'Queue request failed');
for (let i = 0; i < total; i++) {
const album = albums[i];
showToast(`Reorganizing album ${i + 1}/${total}: ${album.title}`, 'info');
const enqueued = result.enqueued || 0;
const already = result.already_queued || 0;
if (enqueued > 0 && already > 0) {
showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''}; ${already} already in queue`, 'info');
} else if (enqueued > 0) {
showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''} for ${artistName}`, 'info');
} else if (already > 0) {
showToast(`All ${already} album${already !== 1 ? 's' : ''} already in queue`, 'info');
} else {
showToast('No albums to queue', 'warning');
}
try {
const resp = await fetch(`/api/library/album/${album.id}/reorganize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: chosenSource }),
if (typeof refreshReorganizeStatusPanel === 'function') {
refreshReorganizeStatusPanel();
}
} catch (err) {
showToast(`Reorganize-all failed: ${err.message}`, 'error');
} finally {
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; }
}
}
// ── Reorganize Status Panel ──
//
// Lives at the start of `.enhanced-artist-meta-actions`. Polls the
// queue snapshot endpoint and renders an at-a-glance summary plus an
// expandable card list. Only visible when there's something to show
// (active item, queued items, or recent completions).
//
// Cross-artist hint: items belonging to a different artist than the
// page's current one are flagged so the user understands progress they
// see refers to a separate batch.
let _reorgPanelEl = null;
let _reorgPanelArtistId = null;
let _reorgPanelExpanded = false;
let _reorgPanelTimer = null;
let _reorgPanelLastSnapshot = null;
let _reorgPanelInflight = false;
const _REORG_PANEL_FAST_MS = 1500;
const _REORG_PANEL_SLOW_MS = 8000;
function mountReorganizeStatusPanel(container, artistId) {
if (!container) return;
// Tear down any panel left over from a previous artist view.
_stopReorganizeStatusPolling();
const panel = document.createElement('div');
panel.className = 'reorganize-status-panel hidden';
panel.id = 'reorganize-status-panel';
container.insertBefore(panel, container.firstChild);
_reorgPanelEl = panel;
_reorgPanelArtistId = artistId || null;
_reorgPanelExpanded = false;
_reorgPanelLastSnapshot = null;
// Defer the initial refresh: the caller (renderArtistMetaPanel) is
// still building the header in memory, so neither this panel nor
// its ancestor headerRight has been attached to document.body yet.
// refreshReorganizeStatusPanel guards on document.body.contains,
// so a synchronous call here would bail and kill polling forever.
// setTimeout 0 lets the call stack unwind so the parent appendChild
// runs before we check connectivity.
setTimeout(() => {
if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) return;
refreshReorganizeStatusPanel();
}, 0);
}
function _stopReorganizeStatusPolling() {
if (_reorgPanelTimer) {
clearTimeout(_reorgPanelTimer);
_reorgPanelTimer = null;
}
_reorgPanelEl = null;
_reorgPanelLastSnapshot = null;
}
function _scheduleReorganizeStatusPoll(delayMs) {
if (_reorgPanelTimer) clearTimeout(_reorgPanelTimer);
_reorgPanelTimer = setTimeout(() => {
_reorgPanelTimer = null;
refreshReorganizeStatusPanel();
}, delayMs);
}
async function refreshReorganizeStatusPanel() {
// The panel may have been unmounted (user navigated away from
// enhanced view); detect by checking it's still in the document.
if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) {
_stopReorganizeStatusPolling();
return;
}
if (_reorgPanelInflight) return;
_reorgPanelInflight = true;
let snapshot = null;
try {
const resp = await fetch('/api/library/reorganize/queue');
if (resp.ok) {
const data = await resp.json();
if (data.success !== false) snapshot = data;
} else {
console.warn('Reorganize queue snapshot HTTP', resp.status);
}
} catch (err) {
// Network blip — keep showing the last snapshot, retry slowly.
console.warn('Reorganize queue snapshot failed:', err);
} finally {
_reorgPanelInflight = false;
}
if (snapshot) _reorgPanelLastSnapshot = snapshot;
_renderReorganizeStatusPanel(_reorgPanelLastSnapshot);
// Reschedule. Fast cadence while there's actually work in flight,
// slow when the queue is empty so we're not hammering the endpoint.
if (_reorgPanelEl && document.body.contains(_reorgPanelEl)) {
const active = _reorgPanelLastSnapshot?.active;
const queued = _reorgPanelLastSnapshot?.queued?.length || 0;
const next = (active || queued > 0) ? _REORG_PANEL_FAST_MS : _REORG_PANEL_SLOW_MS;
_scheduleReorganizeStatusPoll(next);
}
}
function _renderReorganizeStatusPanel(snapshot) {
const panel = _reorgPanelEl;
if (!panel) return;
if (!snapshot) {
panel.classList.add('hidden');
return;
}
const active = snapshot.active;
const queued = snapshot.queued || [];
const recent = snapshot.recent || [];
// Show if anything is active/queued, OR a recent completion landed
// within the last 20 seconds (so the user sees the result).
const cutoffSec = (Date.now() / 1000) - 20;
const recentVisible = recent.filter(r => (r.finished_at || 0) >= cutoffSec);
if (!active && queued.length === 0 && recentVisible.length === 0) {
panel.classList.add('hidden');
panel.innerHTML = '';
_paintQueuedAlbumButtons(snapshot);
return;
}
panel.classList.remove('hidden');
// Compact summary (always visible). Click to toggle expand.
let html = '<div class="reorg-panel-compact" onclick="toggleReorganizeStatusPanel()">';
html += '<div class="reorg-panel-compact-left">';
if (active) {
const total = active.progress_total || 0;
const done = active.progress_processed || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const trackBit = active.current_track ? `${escapeHtml(active.current_track)}` : '';
const albumLabel = _reorgPanelDisplayLabel(active);
html += `<span class="reorg-panel-spinner"></span>`;
html += `<span class="reorg-panel-active-text">Reorganizing <strong>${escapeHtml(albumLabel)}</strong>`;
if (total > 0) html += ` (${done}/${total} · ${pct}%)`;
html += `${trackBit}</span>`;
} else if (queued.length > 0) {
html += `<span class="reorg-panel-spinner"></span>`;
html += `<span class="reorg-panel-active-text">Reorganize queue starting…</span>`;
} else {
// Only recent items remain — give a quick wrap-up summary.
const failed = recentVisible.filter(r => r.status === 'failed').length;
const done = recentVisible.filter(r => r.status === 'done').length;
const cls = failed > 0 ? 'recent-warn' : 'recent-ok';
html += `<span class="reorg-panel-recent-icon ${cls}"></span>`;
const parts = [];
if (done > 0) parts.push(`${done} reorganized`);
if (failed > 0) parts.push(`${failed} failed`);
html += `<span class="reorg-panel-active-text">${parts.join(', ') || 'Recent activity'}</span>`;
}
html += '</div>';
// Right: queue count badge + expand chevron.
html += '<div class="reorg-panel-compact-right">';
if (queued.length > 0) {
html += `<span class="reorg-panel-queue-badge" title="${queued.length} waiting in queue">+${queued.length} queued</span>`;
}
const chev = _reorgPanelExpanded ? '▾' : '▸';
html += `<span class="reorg-panel-chevron">${chev}</span>`;
html += '</div>';
html += '</div>';
if (_reorgPanelExpanded) {
html += '<div class="reorg-panel-expanded">';
// Active card
if (active) {
html += _reorgPanelRenderActiveCard(active);
}
// Queued list
if (queued.length > 0) {
html += '<div class="reorg-panel-section-header">';
html += `<span>Queued (${queued.length})</span>`;
html += `<button class="reorg-panel-clear-btn" onclick="clearReorganizeQueue(event)">Cancel All</button>`;
html += '</div>';
html += '<div class="reorg-panel-list">';
queued.forEach((item, idx) => {
html += _reorgPanelRenderQueuedRow(item, idx + 1);
});
const result = await resp.json();
if (!result.success) {
showToast(`Failed: ${album.title}${result.error || 'unknown error'}`, 'error');
failed++;
continue;
}
html += '</div>';
}
// kettui PR #377 review: don't count any HTTP-success as
// "succeeded" — check the final state's result_status.
// Albums with no source ID, no tracks, etc. complete the
// request but aren't actually reorganized.
const finalState = await _waitForReorganizeComplete();
if (finalState && finalState.result_status === 'completed' && (finalState.failed || 0) === 0) {
succeeded++;
} else if (finalState && finalState.result_status && finalState.result_status !== 'completed') {
skipped++;
showToast(`Skipped: ${album.title}${_formatReorganizeResultMessage(finalState)}`, 'warning');
} else {
// result_status === 'completed' but failed > 0 → partial
failed++;
showToast(`Partial: ${album.title}${_formatReorganizeResultMessage(finalState || {})}`, 'warning');
// Recent
if (recentVisible.length > 0) {
html += `<div class="reorg-panel-section-header"><span>Recent</span></div>`;
html += '<div class="reorg-panel-list">';
recentVisible.slice(0, 6).forEach(item => {
html += _reorgPanelRenderRecentRow(item);
});
html += '</div>';
}
html += '</div>';
}
panel.innerHTML = html;
// Mark per-album reorganize buttons so users see at-a-glance which
// albums are already in the queue without opening the modal.
_paintQueuedAlbumButtons(snapshot);
// If the active item just transitioned to a recent done/failed
// entry, refresh the enhanced view so the new on-disk paths show.
_maybeReloadEnhancedAfterCompletion(snapshot);
}
function _reorganizeStateForAlbum(albumId) {
const snap = _reorgPanelLastSnapshot;
if (!snap) return null;
const id = String(albumId);
if (snap.active && String(snap.active.album_id) === id) return 'running';
if ((snap.queued || []).some(q => String(q.album_id) === id)) return 'queued';
return null;
}
function _paintQueuedAlbumButtons(snapshot) {
const queuedIds = new Set();
const runningIds = new Set();
if (snapshot?.active) runningIds.add(String(snapshot.active.album_id));
(snapshot?.queued || []).forEach(q => queuedIds.add(String(q.album_id)));
document.querySelectorAll('.enhanced-reorganize-album-btn[data-album-id]').forEach(btn => {
const id = btn.dataset.albumId;
if (runningIds.has(id)) {
btn.classList.add('reorg-state-running');
btn.classList.remove('reorg-state-queued');
btn.title = 'Reorganize already running for this album';
} else if (queuedIds.has(id)) {
btn.classList.add('reorg-state-queued');
btn.classList.remove('reorg-state-running');
btn.title = 'Album already queued for reorganize';
} else {
btn.classList.remove('reorg-state-queued', 'reorg-state-running');
btn.title = 'Reorganize album files using your configured download template';
}
});
}
function _reorgPanelDisplayLabel(item) {
if (!item) return '';
if (_reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId) {
return `${item.album_title || 'Unknown album'} (${item.artist_name || 'other artist'})`;
}
return item.album_title || 'Unknown album';
}
function _reorgPanelRenderActiveCard(active) {
const total = active.progress_total || 0;
const done = active.progress_processed || 0;
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
const crossArtist = _reorgPanelArtistId && active.artist_id && String(active.artist_id) !== _reorgPanelArtistId;
let h = '<div class="reorg-panel-active-card">';
h += `<div class="reorg-panel-active-title">${escapeHtml(active.album_title || 'Unknown album')}`;
if (crossArtist) {
h += ` <span class="reorg-panel-cross-artist">${escapeHtml(active.artist_name || 'other artist')}</span>`;
}
h += '</div>';
h += '<div class="reorg-panel-progress-track">';
h += `<div class="reorg-panel-progress-fill" style="width:${pct}%"></div>`;
h += '</div>';
h += '<div class="reorg-panel-active-meta">';
if (total > 0) {
h += `<span>${done}/${total}</span>`;
}
if (active.current_track) {
h += `<span class="reorg-panel-current-track">${escapeHtml(active.current_track)}</span>`;
}
h += '<span class="reorg-panel-counters">';
h += `<span class="ok">${active.moved || 0} moved</span>`;
if ((active.skipped || 0) > 0) h += `<span class="warn">${active.skipped} skipped</span>`;
if ((active.failed || 0) > 0) h += `<span class="fail">${active.failed} failed</span>`;
h += '</span>';
h += '</div>';
h += '</div>';
return h;
}
function _reorgPanelRenderQueuedRow(item, position) {
const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId;
let h = '<div class="reorg-panel-row queued-row">';
h += `<span class="reorg-panel-row-pos">#${position}</span>`;
h += '<div class="reorg-panel-row-body">';
h += `<div class="reorg-panel-row-title">${escapeHtml(item.album_title || 'Unknown album')}</div>`;
if (crossArtist) {
h += `<div class="reorg-panel-row-sub">${escapeHtml(item.artist_name || 'other artist')}</div>`;
} else if (item.source) {
h += `<div class="reorg-panel-row-sub">via ${escapeHtml(item.source)}</div>`;
}
h += '</div>';
h += `<button class="reorg-panel-cancel-btn" title="Cancel" onclick="cancelReorganizeQueueItem('${item.queue_id}', event)">×</button>`;
h += '</div>';
return h;
}
function _reorgPanelRenderRecentRow(item) {
const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId;
const tone = _classifyReorganizeOutcome({
result_status: item.result_status,
failed: item.failed,
});
const cls = item.status === 'cancelled' ? 'cancelled' : tone;
let h = `<div class="reorg-panel-row recent-row ${cls}">`;
h += `<span class="reorg-panel-row-icon ${cls}"></span>`;
h += '<div class="reorg-panel-row-body">';
h += `<div class="reorg-panel-row-title">${escapeHtml(item.album_title || 'Unknown album')}</div>`;
let sub;
if (item.status === 'cancelled') {
sub = 'Cancelled';
} else {
sub = _formatReorganizeResultMessage({
result_status: item.result_status,
moved: item.moved,
skipped: item.skipped,
failed: item.failed,
errors: item.error ? [{ error: item.error }] : [],
});
}
if (crossArtist) sub = `${escapeHtml(item.artist_name || 'other artist')}${sub}`;
h += `<div class="reorg-panel-row-sub">${escapeHtml(sub)}</div>`;
h += '</div></div>';
return h;
}
function toggleReorganizeStatusPanel() {
_reorgPanelExpanded = !_reorgPanelExpanded;
_renderReorganizeStatusPanel(_reorgPanelLastSnapshot);
}
async function cancelReorganizeQueueItem(queueId, event) {
if (event) event.stopPropagation();
if (!queueId) return;
try {
const resp = await fetch(`/api/library/reorganize/queue/${encodeURIComponent(queueId)}/cancel`, {
method: 'POST',
});
const data = await resp.json();
if (data.cancelled) {
showToast('Cancelled queued item', 'info');
} else if (data.reason === 'running_cant_cancel') {
showToast('Already running — too late to cancel', 'warning');
} else {
showToast('Could not cancel item', 'warning');
}
} catch (err) {
showToast(`Cancel failed: ${err.message}`, 'error');
}
refreshReorganizeStatusPanel();
}
async function clearReorganizeQueue(event) {
if (event) event.stopPropagation();
const queued = _reorgPanelLastSnapshot?.queued?.length || 0;
if (queued === 0) return;
const confirmed = await showConfirmDialog({
title: 'Cancel All Queued',
message: `Cancel ${queued} queued reorganize${queued !== 1 ? 's' : ''}? The currently-running item will continue.`,
confirmText: 'Cancel All',
destructive: true,
});
if (!confirmed) return;
try {
const resp = await fetch('/api/library/reorganize/queue/clear', { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast(`Cancelled ${data.cancelled} queued item${data.cancelled !== 1 ? 's' : ''}`, 'info');
}
} catch (err) {
showToast(`Clear failed: ${err.message}`, 'error');
}
refreshReorganizeStatusPanel();
}
let _reorgPanelLastActiveId = null;
let _reorgPanelPendingReload = false;
let _reorgPanelReloadTimer = null;
function _maybeReloadEnhancedAfterCompletion(snapshot) {
// When an item completes for the artist on screen, the moved file
// paths need to be re-rendered in the enhanced view. Two failure
// modes to avoid:
// 1. Reloading mid-batch — a 20-album "Reorganize All" would
// otherwise fire 20 sequential /api/library/artist/X/enhanced
// calls + 20 full re-renders, hammering the server.
// 2. Never reloading — if we wait for queue idle but more items
// keep arriving, the user never sees the freshly-moved paths.
//
// Strategy: mark a reload as pending whenever a completion lands
// for our artist. Defer the reload until the queue is fully idle
// for that artist (no active item, nothing queued) — that's the
// natural "batch finished" boundary. Use a 1.5s timer reset on
// every snapshot so we don't fire while the worker is still
// between items.
const active = snapshot?.active;
const recent = snapshot?.recent || [];
const queued = snapshot?.queued || [];
// Detect a fresh completion (recent-top is a new queue_id we
// hadn't seen as 'active' before) for our artist.
if (active) {
_reorgPanelLastActiveId = active.queue_id;
} else if (_reorgPanelLastActiveId && recent.length > 0) {
const recentTop = recent[0];
if (recentTop.queue_id === _reorgPanelLastActiveId) {
const finishedRecently = (recentTop.finished_at || 0) >= ((Date.now() / 1000) - 10);
const sameArtist = _reorgPanelArtistId &&
recentTop.artist_id && String(recentTop.artist_id) === _reorgPanelArtistId;
if (finishedRecently && sameArtist) {
_reorgPanelPendingReload = true;
}
} catch (err) {
showToast(`Error: ${album.title}${err.message}`, 'error');
failed++;
_reorgPanelLastActiveId = null;
}
}
let msg = `Reorganized ${succeeded} of ${total} album${total !== 1 ? 's' : ''}`;
if (skipped > 0) msg += `, ${skipped} skipped`;
if (failed > 0) msg += `, ${failed} failed`;
showToast(msg, (failed > 0 || skipped > 0) ? 'warning' : 'success');
if (!_reorgPanelPendingReload) return;
_reorganizeAllRunning = false;
if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; }
// Hold the reload until the queue is fully idle for our artist.
const stillBusyForOurArtist = active &&
_reorgPanelArtistId &&
active.artist_id && String(active.artist_id) === _reorgPanelArtistId;
const queuedForOurArtist = queued.some(q =>
_reorgPanelArtistId && q.artist_id && String(q.artist_id) === _reorgPanelArtistId
);
// Refresh enhanced view
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
if (stillBusyForOurArtist || queuedForOurArtist) {
// More work coming for this artist — keep the pending flag,
// don't reload yet. Cancel any already-armed timer.
if (_reorgPanelReloadTimer) {
clearTimeout(_reorgPanelReloadTimer);
_reorgPanelReloadTimer = null;
}
return;
}
// Queue is idle for our artist. Arm a debounced reload — the
// 1.5s gap absorbs the brief window between worker items so a
// back-to-back batch doesn't trigger mid-flight.
if (_reorgPanelReloadTimer) clearTimeout(_reorgPanelReloadTimer);
_reorgPanelReloadTimer = setTimeout(() => {
_reorgPanelReloadTimer = null;
_reorgPanelPendingReload = false;
if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) {
loadEnhancedViewData(artistDetailPageState.currentArtistId);
}
}, 1500);
}
function _waitForReorganizeComplete() {
// Resolves with the final state object so the caller can inspect
// result_status / failed / errors instead of treating every
// completion as success (kettui PR #377 review).
return new Promise(resolve => {
const poll = setInterval(async () => {
try {
const resp = await fetch('/api/library/album/reorganize/status');
const state = await resp.json();
if (state.status === 'done' || state.status === 'idle') {
clearInterval(poll);
resolve(state);
}
} catch {
clearInterval(poll);
resolve(null);
}
}, 800);
});
}
async function playLibraryTrack(track, albumTitle, artistName) {
if (!track.file_path) {

View file

@ -43830,6 +43830,9 @@ a.enhanced-id-badge:visited {
.enhanced-artist-meta-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
.enhanced-meta-save-btn,
.enhanced-meta-cancel-btn {
@ -44668,6 +44671,272 @@ textarea.enhanced-meta-field-input {
cursor: not-allowed;
}
/*
REORGANIZE STATUS PANEL sits at the start of .enhanced-artist-meta-actions
*/
.reorganize-status-panel {
flex: 1 1 100%;
order: -1;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 8px 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
transition: background 0.2s ease, border-color 0.2s ease;
min-width: 0;
}
.reorganize-status-panel.hidden {
display: none;
}
.reorg-panel-compact {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
user-select: none;
min-width: 0;
}
.reorg-panel-compact-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.reorg-panel-compact-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.reorg-panel-active-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.reorg-panel-active-text strong {
color: #ffffff;
font-weight: 600;
}
.reorg-panel-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(var(--accent-rgb), 0.25);
border-top-color: rgb(var(--accent-rgb));
border-radius: 50%;
flex-shrink: 0;
animation: reorgPanelSpin 0.9s linear infinite;
}
@keyframes reorgPanelSpin {
to { transform: rotate(360deg); }
}
.reorg-panel-recent-icon {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.reorg-panel-recent-icon.recent-ok { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.4); }
.reorg-panel-recent-icon.recent-warn { background: #facc15; box-shadow: 0 0 6px rgba(250, 204, 21, 0.4); }
.reorg-panel-queue-badge {
background: rgba(var(--accent-rgb), 0.15);
color: rgb(var(--accent-light-rgb));
border: 1px solid rgba(var(--accent-rgb), 0.3);
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.reorg-panel-chevron {
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
transition: color 0.2s ease;
}
.reorg-panel-compact:hover .reorg-panel-chevron {
color: rgba(255, 255, 255, 0.85);
}
.reorg-panel-expanded {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
flex-direction: column;
gap: 10px;
}
.reorg-panel-active-card {
background: rgba(var(--accent-rgb), 0.06);
border: 1px solid rgba(var(--accent-rgb), 0.18);
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.reorg-panel-active-title {
font-weight: 600;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorg-panel-cross-artist {
margin-left: 6px;
color: rgba(255, 255, 255, 0.55);
font-weight: 400;
font-size: 11px;
font-style: italic;
}
.reorg-panel-progress-track {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
overflow: hidden;
}
.reorg-panel-progress-fill {
height: 100%;
background: linear-gradient(90deg,
rgb(var(--accent-rgb)) 0%,
rgb(var(--accent-light-rgb)) 100%);
border-radius: 2px;
transition: width 0.4s ease;
}
.reorg-panel-active-meta {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
color: rgba(255, 255, 255, 0.65);
font-size: 11px;
}
.reorg-panel-current-track {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.reorg-panel-counters {
display: inline-flex;
gap: 8px;
flex-shrink: 0;
}
.reorg-panel-counters .ok { color: #4ade80; }
.reorg-panel-counters .warn { color: #facc15; }
.reorg-panel-counters .fail { color: #f87171; }
.reorg-panel-section-header {
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.reorg-panel-clear-btn {
background: transparent;
border: 1px solid rgba(248, 113, 113, 0.3);
color: #f87171;
padding: 3px 10px;
border-radius: 5px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.4px;
transition: all 0.15s ease;
}
.reorg-panel-clear-btn:hover {
background: rgba(248, 113, 113, 0.12);
border-color: rgba(248, 113, 113, 0.5);
}
.reorg-panel-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.reorg-panel-row {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 6px;
padding: 6px 10px;
min-width: 0;
}
.reorg-panel-row-pos {
color: rgba(255, 255, 255, 0.35);
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
width: 22px;
}
.reorg-panel-row-body {
flex: 1 1 auto;
min-width: 0;
}
.reorg-panel-row-title {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorg-panel-row-sub {
color: rgba(255, 255, 255, 0.45);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorg-panel-cancel-btn {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.4);
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
line-height: 1;
flex-shrink: 0;
transition: all 0.15s ease;
}
.reorg-panel-cancel-btn:hover {
background: rgba(248, 113, 113, 0.15);
color: #f87171;
}
.reorg-panel-row.recent-row.success { border-color: rgba(74, 222, 128, 0.18); }
.reorg-panel-row.recent-row.warning { border-color: rgba(250, 204, 21, 0.18); }
.reorg-panel-row.recent-row.cancelled { border-color: rgba(255, 255, 255, 0.08); opacity: 0.7; }
.reorg-panel-row-icon {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.reorg-panel-row-icon.success { background: #4ade80; }
.reorg-panel-row-icon.warning { background: #facc15; }
.reorg-panel-row-icon.cancelled { background: rgba(255, 255, 255, 0.3); }
/*
DOWNLOAD DISCOGRAPHY Button + Modal
*/
@ -46160,6 +46429,32 @@ textarea.enhanced-meta-field-input {
color: rgba(100, 149, 237, 0.9);
border-color: rgba(100, 149, 237, 0.35);
}
.enhanced-reorganize-album-btn.reorg-state-queued {
background: rgba(var(--accent-rgb), 0.10);
border-color: rgba(var(--accent-rgb), 0.35);
color: rgb(var(--accent-light-rgb));
}
.enhanced-reorganize-album-btn.reorg-state-queued::before {
content: '⏳ ';
margin-right: 2px;
}
.enhanced-reorganize-album-btn.reorg-state-running {
background: rgba(var(--accent-rgb), 0.18);
border-color: rgba(var(--accent-rgb), 0.55);
color: #ffffff;
}
.enhanced-reorganize-album-btn.reorg-state-running::before {
content: '';
display: inline-block;
width: 9px;
height: 9px;
margin-right: 5px;
border: 2px solid rgba(255,255,255,0.35);
border-top-color: #ffffff;
border-radius: 50%;
animation: reorgPanelSpin 0.9s linear infinite;
vertical-align: -1px;
}
.enhanced-redownload-album-btn {
padding: 6px 14px;