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:
commit
13b4578067
11 changed files with 2598 additions and 249 deletions
453
core/reorganize_queue.py
Normal file
453
core/reorganize_queue.py
Normal 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
123
core/reorganize_runner.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
213
tests/test_reorganize_db_methods.py
Normal file
213
tests/test_reorganize_db_methods.py
Normal 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')
|
||||
479
tests/test_reorganize_queue.py
Normal file
479
tests/test_reorganize_queue.py
Normal 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()
|
||||
235
tests/test_reorganize_runner.py
Normal file
235
tests/test_reorganize_runner.py
Normal 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.
|
||||
292
web_server.py
292
web_server.py
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 = '📁 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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue