Add library repair worker and UI

Introduce a RepairWorker to scan the transfer folder and automatically detect/repair broken album track numbers (e.g. the "all tracks = 01" bug). The worker uses mutagen to read/write tags, fuzzy-matches titles against an album tracklist (Spotify/iTunes via a SpotifyClient), updates filenames and the tracks DB file_path when renamed, and caches album tracklists. It also adds DB schema support (repair_status, repair_last_checked, and an index).

Integrates the worker into the web server: initializes and starts the worker, and exposes /api/repair/status, /api/repair/pause and /api/repair/resume endpoints. Adds UI elements (button, tooltip), client-side JS to poll and control the worker, CSS for visuals/animations, and a new image asset (whisoul.png).
This commit is contained in:
Broque Thomas 2026-02-21 15:00:23 -08:00
parent 11b6c6db97
commit ce474749d5
7 changed files with 1195 additions and 0 deletions

710
core/repair_worker.py Normal file
View file

@ -0,0 +1,710 @@
import os
import re
import threading
import time
from difflib import SequenceMatcher
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from config.settings import config_manager
logger = get_logger("repair_worker")
AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac', '.wav', '.wma', '.aiff', '.aif'}
class RepairWorker:
"""Background worker for scanning and repairing library files.
Currently supports:
- Track number repair: fixes embedded tracknumber tags and filename
prefixes using the album tracklist from Spotify/iTunes as the
authoritative source.
Designed to be extended with additional repair types over time.
"""
def __init__(self, database: MusicDatabase, transfer_folder: str = None):
self.db = database
# Resolve transfer folder
if transfer_folder:
self.transfer_folder = transfer_folder
else:
raw = config_manager.get('soulseek.transfer_path', './Transfer')
# docker_resolve_path is in web_server — keep it simple here
self.transfer_folder = raw
# Worker state
self.running = False
self.paused = False
self.should_stop = False
self.thread = None
# Current item being processed (for UI tooltip)
self.current_item = None
# Statistics
self.stats = {
'scanned': 0,
'repaired': 0,
'skipped': 0,
'errors': 0,
'pending': 0
}
# How often to re-scan the full library (hours)
self.rescan_interval_hours = 24
# Album tracks cache: album_id -> list of track dicts
self._album_tracks_cache: Dict[str, List[Dict]] = {}
# Title matching threshold
self.title_similarity_threshold = 0.80
# SpotifyClient (lazy-init to avoid circular imports)
self._spotify_client = None
logger.info("Repair worker initialized (transfer_folder=%s)", self.transfer_folder)
# ------------------------------------------------------------------
# Lazy SpotifyClient accessor
# ------------------------------------------------------------------
@property
def spotify_client(self):
if self._spotify_client is None:
try:
from core.spotify_client import SpotifyClient
self._spotify_client = SpotifyClient()
except Exception as e:
logger.error("Failed to initialize SpotifyClient: %s", e)
return self._spotify_client
# ------------------------------------------------------------------
# Lifecycle (identical to AudioDB worker)
# ------------------------------------------------------------------
def start(self):
if self.running:
logger.warning("Repair worker already running")
return
self.running = True
self.should_stop = False
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
logger.info("Repair worker started")
def stop(self):
if not self.running:
return
logger.info("Stopping repair worker...")
self.should_stop = True
self.running = False
if self.thread:
self.thread.join(timeout=5)
logger.info("Repair worker stopped")
def pause(self):
if not self.running:
logger.warning("Repair worker not running, cannot pause")
return
self.paused = True
logger.info("Repair worker paused")
def resume(self):
if not self.running:
logger.warning("Repair worker not running, start it first")
return
self.paused = False
logger.info("Repair worker resumed")
def get_stats(self) -> Dict[str, Any]:
is_actually_running = self.running and (self.thread is not None and self.thread.is_alive())
is_idle = (
is_actually_running
and not self.paused
and self.stats['pending'] == 0
and self.current_item is None
)
return {
'enabled': True,
'running': is_actually_running and not self.paused,
'paused': self.paused,
'idle': is_idle,
'current_item': self.current_item,
'stats': self.stats.copy(),
'progress': self._get_progress()
}
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def _run(self):
logger.info("Repair worker thread started")
while not self.should_stop:
try:
if self.paused:
time.sleep(1)
continue
self.current_item = None
# Reset stats for a new scan pass
self.stats = {
'scanned': 0,
'repaired': 0,
'skipped': 0,
'errors': 0,
'pending': 0
}
self._album_tracks_cache.clear()
self._scan_library()
# Done scanning — go idle until next interval
self.current_item = None
self.stats['pending'] = 0
logger.info(
"Repair scan complete. Scanned=%d Repaired=%d Skipped=%d Errors=%d",
self.stats['scanned'], self.stats['repaired'],
self.stats['skipped'], self.stats['errors']
)
# Sleep until next scan (check should_stop / paused periodically)
sleep_until = time.time() + self.rescan_interval_hours * 3600
while time.time() < sleep_until and not self.should_stop:
if self.paused:
time.sleep(1)
continue
time.sleep(10)
except Exception as e:
logger.error("Error in repair worker loop: %s", e, exc_info=True)
time.sleep(30)
logger.info("Repair worker thread finished")
# ------------------------------------------------------------------
# Library scanning
# ------------------------------------------------------------------
def _scan_library(self):
"""Walk the transfer folder and process album folders."""
transfer = self.transfer_folder
if not os.path.isdir(transfer):
logger.warning("Transfer folder does not exist: %s", transfer)
return
# Collect album folders (directories containing audio files)
album_folders: Dict[str, List[str]] = {}
for root, _dirs, files in os.walk(transfer):
if self.should_stop:
return
for fname in files:
ext = os.path.splitext(fname)[1].lower()
if ext in AUDIO_EXTENSIONS:
album_folders.setdefault(root, []).append(fname)
self.stats['pending'] = len(album_folders)
logger.info("Found %d album folders to scan", len(album_folders))
for folder_path, filenames in album_folders.items():
if self.should_stop:
return
if self.paused:
while self.paused and not self.should_stop:
time.sleep(1)
if self.should_stop:
return
folder_name = os.path.basename(folder_path)
self.current_item = {'type': 'album', 'name': folder_name}
try:
self._repair_album_track_numbers(folder_path, filenames)
except Exception as e:
logger.error("Error processing album folder %s: %s", folder_path, e, exc_info=True)
self.stats['errors'] += 1
self.stats['pending'] -= 1
time.sleep(1) # Rate limit for API calls
# ------------------------------------------------------------------
# Album-level track number repair
# ------------------------------------------------------------------
def _repair_album_track_numbers(self, folder_path: str, filenames: List[str]):
"""Repair track numbers for all files in an album folder.
Targeting logic:
1. Read the track number tag from every file in the folder.
2. Count how many files share the same track number.
3. If 3+ files have the same track number, the album is flagged
as broken (the "all tracks = 01" bug pattern).
Threshold is 3 (not 2) because lossy-copy mode can legitimately
produce two files with the same track number in different qualities.
4. Only then do we spend an API call to get the correct tracklist
and repair each file.
"""
from mutagen import File as MutagenFile
# --- Step 0: Anomaly detection — are track numbers broken? ---
track_num_counts: Dict[int, int] = {} # track_number -> count of files with that number
file_track_data: List[Tuple[str, str, Optional[int]]] = [] # (path, filename, track_num)
for fname in filenames:
fpath = os.path.join(folder_path, fname)
try:
audio = MutagenFile(fpath)
if audio is None:
file_track_data.append((fpath, fname, None))
continue
track_num, _ = self._read_track_number_tag(audio)
file_track_data.append((fpath, fname, track_num))
if track_num is not None:
track_num_counts[track_num] = track_num_counts.get(track_num, 0) + 1
except Exception:
file_track_data.append((fpath, fname, None))
# Check if any single track number appears on 3+ files
has_anomaly = any(count >= 3 for count in track_num_counts.values())
if not has_anomaly:
# Album looks fine — count as scanned, no repair needed
self.stats['scanned'] += len(filenames)
return
# Log which track number(s) are duplicated
duped = {num: cnt for num, cnt in track_num_counts.items() if cnt >= 3}
logger.info(
"Anomaly detected in %s%d files share track number(s): %s",
os.path.basename(folder_path), sum(duped.values()), duped
)
# --- Step 1: Read album ID from file tags ---
album_id = None
id_source = None
for fpath, fname, _ in file_track_data:
album_id, id_source = self._read_album_id_from_file(fpath)
if album_id:
break
if not album_id:
logger.debug("No album ID found in any file in %s — skipping", folder_path)
self.stats['skipped'] += len(filenames)
self.stats['scanned'] += len(filenames)
return
# --- Step 2: Fetch album tracklist from API (with cache) ---
api_tracks = self._get_album_tracklist(album_id)
if not api_tracks:
logger.debug("API returned no tracks for album %s — skipping %s", album_id, folder_path)
self.stats['skipped'] += len(filenames)
self.stats['scanned'] += len(filenames)
return
# --- Step 3-5: Process each file ---
for fpath, fname, _ in file_track_data:
if self.should_stop:
return
self.stats['scanned'] += 1
try:
self._repair_single_track(fpath, fname, api_tracks)
except Exception as e:
logger.error("Error repairing %s: %s", fpath, e, exc_info=True)
self.stats['errors'] += 1
def _repair_single_track(self, file_path: str, filename: str, api_tracks: List[Dict]):
"""Check and repair a single track's track number."""
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TRCK
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
# Rigid check: file must exist
if not os.path.isfile(file_path):
logger.debug("File missing: %s", file_path)
self.stats['skipped'] += 1
return
# Rigid check: must be audio
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:
self.stats['skipped'] += 1
return
# Read current metadata
audio = MutagenFile(file_path)
if audio is None:
logger.debug("Mutagen cannot open: %s", file_path)
self.stats['skipped'] += 1
return
current_title = self._read_title_tag(audio)
current_track_num, current_total = self._read_track_number_tag(audio)
filename_track_num = self._extract_track_number_from_filename(filename)
if not current_title:
logger.debug("No title tag in %s — skipping", filename)
self.stats['skipped'] += 1
return
# Match against API tracklist
matched_track = self._match_title_to_api_track(current_title, api_tracks)
if not matched_track:
logger.debug("No API match for title '%s' in %s — skipping", current_title, filename)
self.stats['skipped'] += 1
return
correct_track_num = matched_track.get('track_number')
if correct_track_num is None:
logger.debug("API track has no track_number for '%s' — skipping", current_title)
self.stats['skipped'] += 1
return
# Compare
metadata_wrong = (current_track_num != correct_track_num)
filename_wrong = (filename_track_num is not None and filename_track_num != correct_track_num)
if not metadata_wrong and not filename_wrong:
# Everything correct
return
# Determine total_tracks for the tag
total_tracks = current_total or len(api_tracks)
logger.info(
"Repairing track: %s — correct=#%d, current_tag=#%s, current_filename=#%s",
filename, correct_track_num, current_track_num, filename_track_num
)
# Step 5a: Fix metadata tag
if metadata_wrong:
self._fix_track_number_tag(file_path, audio, correct_track_num, total_tracks)
# Step 5b: Fix filename
if filename_wrong:
new_path = self._fix_filename_track_number(file_path, filename, correct_track_num)
if new_path:
# Update DB file_path if tracked
self._update_db_file_path(file_path, new_path)
self.stats['repaired'] += 1
# ------------------------------------------------------------------
# Tag reading helpers
# ------------------------------------------------------------------
def _read_album_id_from_file(self, file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""Read SPOTIFY_ALBUM_ID or ITUNES_ALBUM_ID from embedded tags.
Returns (album_id, source) where source is 'spotify' or 'itunes'."""
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
audio = MutagenFile(file_path)
if audio is None:
return None, None
# MP3 (ID3) — TXXX frames
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
for key in ['TXXX:SPOTIFY_ALBUM_ID', 'TXXX:spotify_album_id']:
frame = audio.tags.getall(key)
if frame and frame[0].text:
return str(frame[0].text[0]), 'spotify'
for key in ['TXXX:ITUNES_ALBUM_ID', 'TXXX:itunes_album_id']:
frame = audio.tags.getall(key)
if frame and frame[0].text:
return str(frame[0].text[0]), 'itunes'
# FLAC / OggVorbis — VorbisComment (lowercase keys)
elif isinstance(audio, (FLAC, OggVorbis)):
for key in ['spotify_album_id', 'SPOTIFY_ALBUM_ID']:
val = audio.get(key)
if val:
return str(val[0]), 'spotify'
for key in ['itunes_album_id', 'ITUNES_ALBUM_ID']:
val = audio.get(key)
if val:
return str(val[0]), 'itunes'
# MP4/M4A — freeform tags
elif isinstance(audio, MP4):
for key in ['----:com.apple.iTunes:SPOTIFY_ALBUM_ID',
'----:com.apple.iTunes:spotify_album_id']:
val = audio.tags.get(key)
if val:
raw = val[0]
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw), 'spotify'
for key in ['----:com.apple.iTunes:ITUNES_ALBUM_ID',
'----:com.apple.iTunes:itunes_album_id']:
val = audio.tags.get(key)
if val:
raw = val[0]
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw), 'itunes'
except Exception as e:
logger.debug("Error reading album ID from %s: %s", file_path, e)
return None, None
def _read_title_tag(self, audio) -> Optional[str]:
"""Read the title tag from an already-opened Mutagen file."""
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
try:
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
frames = audio.tags.getall('TIT2')
if frames and frames[0].text:
return str(frames[0].text[0])
elif isinstance(audio, (FLAC, OggVorbis)):
val = audio.get('title')
if val:
return str(val[0])
elif isinstance(audio, MP4):
val = audio.tags.get('\xa9nam')
if val:
return str(val[0])
except Exception as e:
logger.debug("Error reading title tag: %s", e)
return None
def _read_track_number_tag(self, audio) -> Tuple[Optional[int], Optional[int]]:
"""Read track number and total from tags. Returns (track_num, total)."""
from mutagen.id3 import ID3
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
try:
if hasattr(audio, 'tags') and audio.tags is not None:
if isinstance(audio.tags, ID3):
frames = audio.tags.getall('TRCK')
if frames and frames[0].text:
return self._parse_track_str(str(frames[0].text[0]))
elif isinstance(audio, (FLAC, OggVorbis)):
val = audio.get('tracknumber')
if val:
return self._parse_track_str(str(val[0]))
elif isinstance(audio, MP4):
val = audio.tags.get('trkn')
if val and val[0]:
t = val[0]
return (int(t[0]), int(t[1]) if t[1] else None)
except Exception as e:
logger.debug("Error reading track number tag: %s", e)
return None, None
@staticmethod
def _parse_track_str(s: str) -> Tuple[Optional[int], Optional[int]]:
"""Parse '5/12' or '5' into (track_num, total)."""
try:
if '/' in s:
parts = s.split('/')
return int(parts[0]), int(parts[1])
return int(s), None
except (ValueError, IndexError):
return None, None
@staticmethod
def _extract_track_number_from_filename(filename: str) -> Optional[int]:
"""Extract leading track number from filename like '01 - Song.flac'."""
basename = os.path.splitext(filename)[0]
match = re.match(r'^(\d{1,3})', basename.strip())
if match:
return int(match.group(1))
return None
# ------------------------------------------------------------------
# API lookup
# ------------------------------------------------------------------
def _get_album_tracklist(self, album_id: str) -> Optional[List[Dict]]:
"""Fetch album tracks from Spotify/iTunes API with caching."""
if album_id in self._album_tracks_cache:
return self._album_tracks_cache[album_id]
client = self.spotify_client
if not client:
logger.warning("No SpotifyClient available for album lookup")
return None
try:
result = client.get_album_tracks(album_id)
if not result or 'items' not in result:
logger.debug("No tracks returned for album %s", album_id)
self._album_tracks_cache[album_id] = None
return None
tracks = []
for item in result['items']:
tracks.append({
'name': item.get('name', ''),
'track_number': item.get('track_number'),
'disc_number': item.get('disc_number', 1),
})
self._album_tracks_cache[album_id] = tracks
return tracks
except Exception as e:
logger.error("Error fetching album tracks for %s: %s", album_id, e)
return None
# ------------------------------------------------------------------
# Title matching
# ------------------------------------------------------------------
def _match_title_to_api_track(self, file_title: str, api_tracks: List[Dict]) -> Optional[Dict]:
"""Fuzzy-match a file title to an API track. Returns the best match or None."""
norm_file = self._normalize_title(file_title)
best_match = None
best_score = 0.0
for track in api_tracks:
api_name = track.get('name', '')
norm_api = self._normalize_title(api_name)
score = SequenceMatcher(None, norm_file, norm_api).ratio()
if score > best_score:
best_score = score
best_match = track
if best_score >= self.title_similarity_threshold:
return best_match
return None
@staticmethod
def _normalize_title(title: str) -> str:
"""Normalize a title for comparison: lowercase, strip parentheticals, punctuation."""
t = title.lower()
t = re.sub(r'\(.*?\)', '', t)
t = re.sub(r'\[.*?\]', '', t)
t = re.sub(r'[^a-z0-9 ]', '', t)
return t.strip()
# ------------------------------------------------------------------
# Repair actions
# ------------------------------------------------------------------
def _fix_track_number_tag(self, file_path: str, audio, correct_num: int, total: int):
"""Update ONLY the track number tag in the file. Touches nothing else."""
from mutagen import File as MutagenFile
from mutagen.id3 import ID3, TRCK
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
from mutagen.mp4 import MP4
try:
# Re-open file fresh to avoid stale state
audio = MutagenFile(file_path)
if audio is None:
logger.error("Cannot re-open file for tag fix: %s", file_path)
return
track_str = f"{correct_num}/{total}"
if isinstance(audio.tags, ID3):
# Remove existing TRCK, add new one
audio.tags.delall('TRCK')
audio.tags.add(TRCK(encoding=3, text=[track_str]))
audio.save(v1=0, v2_version=4)
elif isinstance(audio, (FLAC, OggVorbis)):
audio['tracknumber'] = [track_str]
if isinstance(audio, FLAC):
audio.save(deleteid3=True)
else:
audio.save()
elif isinstance(audio, MP4):
audio['trkn'] = [(correct_num, total)]
audio.save()
logger.info("Fixed track tag: %s%s", os.path.basename(file_path), track_str)
except Exception as e:
logger.error("Error fixing track tag in %s: %s", file_path, e, exc_info=True)
self.stats['errors'] += 1
def _fix_filename_track_number(self, file_path: str, filename: str, correct_num: int) -> Optional[str]:
"""Fix the track number prefix in a filename. Returns new path or None."""
try:
basename = os.path.splitext(filename)[0]
ext = os.path.splitext(filename)[1]
# Replace leading digits
new_basename = re.sub(r'^\d{1,3}', f'{correct_num:02d}', basename)
if new_basename == basename:
# No change needed (shouldn't happen if filename_wrong was True)
return None
new_filename = new_basename + ext
parent_dir = os.path.dirname(file_path)
new_path = os.path.join(parent_dir, new_filename)
# Rigid checks
if not os.path.isfile(file_path):
logger.error("Source file disappeared before rename: %s", file_path)
return None
if os.path.exists(new_path):
logger.warning("Target path already exists, skipping rename: %s", new_path)
self.stats['skipped'] += 1
return None
os.rename(file_path, new_path)
logger.info("Renamed: %s%s", filename, new_filename)
return new_path
except Exception as e:
logger.error("Error renaming %s: %s", file_path, e, exc_info=True)
self.stats['errors'] += 1
return None
# ------------------------------------------------------------------
# DB helpers
# ------------------------------------------------------------------
def _update_db_file_path(self, old_path: str, new_path: str):
"""Update file_path in tracks table if this track is tracked."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE file_path = ?",
(new_path, old_path)
)
if cursor.rowcount > 0:
conn.commit()
logger.debug("Updated DB file_path: %s%s", old_path, new_path)
else:
conn.commit()
except Exception as e:
logger.debug("Error updating DB file_path: %s", e)
finally:
if conn:
conn.close()
# ------------------------------------------------------------------
# Progress
# ------------------------------------------------------------------
def _get_progress(self) -> Dict[str, Any]:
total = self.stats['scanned'] + self.stats['pending']
percent = round(self.stats['scanned'] / total * 100) if total > 0 else 0
return {
'tracks': {
'total': total,
'checked': self.stats['scanned'],
'repaired': self.stats['repaired'],
'ok': self.stats['scanned'] - self.stats['repaired'] - self.stats['skipped'] - self.stats['errors'],
'skipped': self.stats['skipped'],
'percent': percent
}
}

View file

@ -1202,6 +1202,18 @@ class MusicDatabase:
logger.error(f"Error adding Deezer columns: {e}")
# Don't raise - this is a migration, database can still function
# --- Repair worker columns ---
try:
if 'repair_status' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN repair_status TEXT")
if 'repair_last_checked' not in tracks_columns:
cursor.execute("ALTER TABLE tracks ADD COLUMN repair_last_checked TIMESTAMP")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_tracks_repair_status ON tracks (repair_status)")
except Exception as e:
logger.error(f"Error adding repair columns: {e}")
def close(self):
"""Close database connection (no-op since we create connections per operation)"""
# Each operation creates and closes its own connection, so nothing to do here

View file

@ -26357,6 +26357,79 @@ def hydrabase_worker_resume():
# ================================================================================================
# ================================================================================================
# LIBRARY REPAIR WORKER
# ================================================================================================
from core.repair_worker import RepairWorker
repair_worker = None
try:
from database.music_database import MusicDatabase
repair_db = MusicDatabase()
transfer_path = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
repair_worker = RepairWorker(database=repair_db, transfer_folder=transfer_path)
repair_worker.start()
print("✅ Repair worker initialized and started")
except Exception as e:
print(f"⚠️ Repair worker initialization failed: {e}")
repair_worker = None
# --- Repair Worker API Endpoints ---
@app.route('/api/repair/status', methods=['GET'])
def repair_status():
"""Get repair worker status for UI polling"""
try:
if repair_worker is None:
return jsonify({
'enabled': False,
'running': False,
'paused': False,
'current_item': None,
'stats': {'scanned': 0, 'repaired': 0, 'skipped': 0, 'errors': 0, 'pending': 0},
'progress': {}
}), 200
status = repair_worker.get_stats()
return jsonify(status), 200
except Exception as e:
logger.error(f"Error getting repair status: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/repair/pause', methods=['POST'])
def repair_pause():
"""Pause repair worker"""
try:
if repair_worker is None:
return jsonify({'error': 'Repair worker not initialized'}), 400
repair_worker.pause()
logger.info("Repair worker paused via UI")
return jsonify({'status': 'paused'}), 200
except Exception as e:
logger.error(f"Error pausing repair worker: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/repair/resume', methods=['POST'])
def repair_resume():
"""Resume repair worker"""
try:
if repair_worker is None:
return jsonify({'error': 'Repair worker not initialized'}), 400
repair_worker.resume()
logger.info("Repair worker resumed via UI")
return jsonify({'status': 'running'}), 200
except Exception as e:
logger.error(f"Error resuming repair worker: {e}")
return jsonify({'error': str(e)}), 500
# ================================================================================================
# END LIBRARY REPAIR WORKER
# ================================================================================================
# ================================================================================================
# IMPORT / STAGING SYSTEM
# ================================================================================================

View file

@ -262,6 +262,28 @@
</div>
</div>
</div>
<!-- Library Repair Worker Status Icon -->
<div class="repair-button-container">
<button class="repair-button" id="repair-button" title="Library Repair Worker">
<img src="/static/whisoul.png" alt="Repair" class="repair-logo">
<div class="repair-spinner"></div>
</button>
<!-- Repair Worker Tooltip -->
<div class="repair-tooltip" id="repair-tooltip">
<div class="repair-tooltip-content">
<div class="repair-tooltip-header">🔧 Library Repair</div>
<div class="repair-tooltip-body" id="repair-tooltip-body">
<div class="tooltip-status">Status: <span
id="repair-tooltip-status">Idle</span>
</div>
<div class="tooltip-current" id="repair-tooltip-current">No active repairs
</div>
<div class="tooltip-progress" id="repair-tooltip-progress">Progress: 0 / 0
</div>
</div>
</div>
</div>
</div>
<button class="import-button" id="import-button" onclick="openImportModal()"
title="Import Music from Staging">
<img src="https://cdn-icons-png.flaticon.com/512/8765/8765164.png" alt="Import"

View file

@ -36121,6 +36121,117 @@ if (document.readyState === 'loading') {
}
}
// ===================================================================
// LIBRARY REPAIR WORKER
// ===================================================================
async function updateRepairStatus() {
try {
const response = await fetch('/api/repair/status');
if (!response.ok) {
console.warn('Repair status endpoint unavailable');
return;
}
const data = await response.json();
const button = document.getElementById('repair-button');
if (!button) return;
// Update button state classes
button.classList.remove('active', 'paused', 'complete');
if (data.idle) {
button.classList.add('complete');
} else if (data.running && !data.paused) {
button.classList.add('active');
} else if (data.paused) {
button.classList.add('paused');
}
// Update tooltip content
const tooltipStatus = document.getElementById('repair-tooltip-status');
const tooltipCurrent = document.getElementById('repair-tooltip-current');
const tooltipProgress = document.getElementById('repair-tooltip-progress');
if (tooltipStatus) {
if (data.idle) {
tooltipStatus.textContent = 'Complete';
} else if (data.running && !data.paused) {
tooltipStatus.textContent = 'Running';
} else if (data.paused) {
tooltipStatus.textContent = 'Paused';
} else {
tooltipStatus.textContent = 'Idle';
}
}
if (tooltipCurrent) {
if (data.idle) {
tooltipCurrent.textContent = 'All items processed';
} else if (data.current_item && data.current_item.name) {
const type = data.current_item.type || 'item';
const name = data.current_item.name;
tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`;
} else {
tooltipCurrent.textContent = 'No active repairs';
}
}
if (tooltipProgress && data.progress) {
const tracks = data.progress.tracks || {};
tooltipProgress.textContent = `Checked: ${tracks.checked || 0} / ${tracks.total || 0} (${tracks.percent || 0}%) | Repaired: ${tracks.repaired || 0}`;
}
} catch (error) {
console.error('Error updating repair status:', error);
}
}
/**
* Toggle repair worker pause/resume
*/
async function toggleRepairWorker() {
try {
const button = document.getElementById('repair-button');
if (!button) return;
const isRunning = button.classList.contains('active');
const endpoint = isRunning ? '/api/repair/pause' : '/api/repair/resume';
const response = await fetch(endpoint, { method: 'POST' });
if (!response.ok) {
throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} repair worker`);
}
// Immediately update UI
await updateRepairStatus();
console.log(`Repair worker ${isRunning ? 'paused' : 'resumed'}`);
} catch (error) {
console.error('Error toggling repair worker:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
// Initialize Repair Worker UI on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
const button = document.getElementById('repair-button');
if (button) {
button.addEventListener('click', toggleRepairWorker);
updateRepairStatus();
setInterval(updateRepairStatus, 2000);
}
});
} else {
const button = document.getElementById('repair-button');
if (button) {
button.addEventListener('click', toggleRepairWorker);
updateRepairStatus();
setInterval(updateRepairStatus, 2000);
}
}
// ===================================================================
// IMPORT / STAGING SYSTEM
// ===================================================================

View file

@ -22977,6 +22977,273 @@ body {
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
}
/* ============================================================================
LIBRARY REPAIR WORKER STATUS BUTTON
============================================================================ */
/* Rainbow color animation */
@keyframes rainbow-glow {
0% { box-shadow: 0 4px 16px rgba(255, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(255, 0, 0, 0.4); }
17% { box-shadow: 0 4px 16px rgba(255, 136, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(255, 136, 0, 0.4); }
33% { box-shadow: 0 4px 16px rgba(255, 255, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(255, 255, 0, 0.4); }
50% { box-shadow: 0 4px 16px rgba(0, 255, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(0, 255, 0, 0.4); }
67% { box-shadow: 0 4px 16px rgba(0, 136, 255, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(0, 136, 255, 0.4); }
83% { box-shadow: 0 4px 16px rgba(136, 0, 255, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(136, 0, 255, 0.4); }
100% { box-shadow: 0 4px 16px rgba(255, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.15); border-color: rgba(255, 0, 0, 0.4); }
}
@keyframes rainbow-border {
0% { border-color: rgba(255, 0, 0, 0.5); }
17% { border-color: rgba(255, 136, 0, 0.5); }
33% { border-color: rgba(255, 255, 0, 0.5); }
50% { border-color: rgba(0, 255, 0, 0.5); }
67% { border-color: rgba(0, 136, 255, 0.5); }
83% { border-color: rgba(136, 0, 255, 0.5); }
100% { border-color: rgba(255, 0, 0, 0.5); }
}
@keyframes rainbow-spinner {
0% { border-top-color: rgba(255, 0, 0, 0.8); border-right-color: rgba(255, 136, 0, 0.5); }
17% { border-top-color: rgba(255, 136, 0, 0.8); border-right-color: rgba(255, 255, 0, 0.5); }
33% { border-top-color: rgba(255, 255, 0, 0.8); border-right-color: rgba(0, 255, 0, 0.5); }
50% { border-top-color: rgba(0, 255, 0, 0.8); border-right-color: rgba(0, 136, 255, 0.5); }
67% { border-top-color: rgba(0, 136, 255, 0.8); border-right-color: rgba(136, 0, 255, 0.5); }
83% { border-top-color: rgba(136, 0, 255, 0.8); border-right-color: rgba(255, 0, 0, 0.5); }
100% { border-top-color: rgba(255, 0, 0, 0.8); border-right-color: rgba(255, 136, 0, 0.5); }
}
@keyframes repair-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Container */
.repair-button-container {
position: relative;
display: inline-block;
margin-right: 12px;
}
/* Base Button */
.repair-button {
position: relative;
width: 44px;
height: 44px;
background: linear-gradient(135deg,
rgba(180, 130, 255, 0.12) 0%,
rgba(130, 80, 220, 0.18) 100%);
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1.5px solid rgba(180, 130, 255, 0.25);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow:
0 4px 16px rgba(180, 130, 255, 0.2),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.repair-button:hover {
background: linear-gradient(135deg,
rgba(180, 130, 255, 0.18) 0%,
rgba(130, 80, 220, 0.25) 100%);
border-color: rgba(180, 130, 255, 0.4);
transform: scale(1.05);
box-shadow:
0 6px 20px rgba(180, 130, 255, 0.3),
0 3px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.repair-button:active {
transform: scale(0.95);
box-shadow:
0 2px 8px rgba(180, 130, 255, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
/* Logo */
.repair-logo {
width: 24px;
height: 24px;
object-fit: cover;
border-radius: 50%;
opacity: 0.85;
transition: opacity 0.3s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
.repair-button:hover .repair-logo {
opacity: 1;
}
/* Spinner Ring */
.repair-spinner {
position: absolute;
width: 44px;
height: 44px;
border: 3px solid transparent;
border-top-color: rgba(180, 130, 255, 0.8);
border-right-color: rgba(180, 130, 255, 0.5);
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
/* Active State — Rainbow! */
.repair-button.active .repair-spinner {
opacity: 1;
animation: repair-spin 1.2s linear infinite, rainbow-spinner 3s linear infinite;
}
.repair-button.active {
animation: rainbow-glow 3s linear infinite;
background: linear-gradient(135deg,
rgba(180, 130, 255, 0.22) 0%,
rgba(130, 80, 220, 0.28) 100%);
}
.repair-button.active .repair-logo {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(180, 130, 255, 0.6));
}
/* Complete/Idle State */
.repair-button.complete {
background: linear-gradient(135deg,
rgba(76, 175, 80, 0.15) 0%,
rgba(56, 142, 60, 0.20) 100%);
border-color: rgba(76, 175, 80, 0.35);
box-shadow:
0 4px 16px rgba(76, 175, 80, 0.2),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.repair-button.complete .repair-logo {
opacity: 0.85;
filter: drop-shadow(0 0 6px rgba(76, 175, 80, 0.5));
}
.repair-button.complete .repair-spinner {
opacity: 0;
}
/* Paused State */
.repair-button.paused {
background: linear-gradient(135deg,
rgba(255, 193, 7, 0.12) 0%,
rgba(255, 152, 0, 0.18) 100%);
border-color: rgba(255, 193, 7, 0.35);
box-shadow:
0 4px 16px rgba(255, 193, 7, 0.2),
0 2px 8px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.repair-button.paused:hover {
border-color: rgba(255, 193, 7, 0.5);
box-shadow:
0 6px 20px rgba(255, 193, 7, 0.3),
0 3px 12px rgba(0, 0, 0, 0.2);
}
/* ============================================================================
REPAIR WORKER HOVER TOOLTIP
============================================================================ */
.repair-tooltip {
position: absolute;
left: 50%;
top: calc(100% + 12px);
transform: translateX(-50%) translateY(-5px);
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
.repair-button:hover+.repair-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.repair-tooltip-content {
min-width: 280px;
background: linear-gradient(135deg,
rgba(30, 30, 30, 0.98) 0%,
rgba(20, 20, 20, 0.99) 100%);
backdrop-filter: blur(40px) saturate(1.6);
-webkit-backdrop-filter: blur(40px) saturate(1.6);
border: 1px solid rgba(180, 130, 255, 0.3);
border-radius: 16px;
padding: 16px 18px;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.5),
0 6px 20px rgba(180, 130, 255, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.repair-tooltip-header {
font-family: 'SF Pro Display', -apple-system, sans-serif;
font-size: 13px;
font-weight: 600;
color: rgba(180, 130, 255, 0.95);
letter-spacing: -0.2px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.repair-tooltip-body {
display: flex;
flex-direction: column;
gap: 8px;
}
#repair-tooltip-status {
color: #1ed760;
font-weight: 600;
}
.repair-button.paused+.repair-tooltip #repair-tooltip-status {
color: #ffc107;
}
/* Tooltip Arrow */
.repair-tooltip-content::before {
content: '';
position: absolute;
left: 50%;
top: -8px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid rgba(180, 130, 255, 0.3);
}
.repair-tooltip-content::after {
content: '';
position: absolute;
left: 50%;
top: -7px;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
}
/* MusicBrainz Integration */
.release-card {
position: relative !important;

BIN
webui/static/whisoul.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB