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:
parent
11b6c6db97
commit
ce474749d5
7 changed files with 1195 additions and 0 deletions
710
core/repair_worker.py
Normal file
710
core/repair_worker.py
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1202,6 +1202,18 @@ class MusicDatabase:
|
||||||
logger.error(f"Error adding Deezer columns: {e}")
|
logger.error(f"Error adding Deezer columns: {e}")
|
||||||
# Don't raise - this is a migration, database can still function
|
# 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):
|
def close(self):
|
||||||
"""Close database connection (no-op since we create connections per operation)"""
|
"""Close database connection (no-op since we create connections per operation)"""
|
||||||
# Each operation creates and closes its own connection, so nothing to do here
|
# Each operation creates and closes its own connection, so nothing to do here
|
||||||
|
|
|
||||||
|
|
@ -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
|
# IMPORT / STAGING SYSTEM
|
||||||
# ================================================================================================
|
# ================================================================================================
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,28 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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()"
|
<button class="import-button" id="import-button" onclick="openImportModal()"
|
||||||
title="Import Music from Staging">
|
title="Import Music from Staging">
|
||||||
<img src="https://cdn-icons-png.flaticon.com/512/8765/8765164.png" alt="Import"
|
<img src="https://cdn-icons-png.flaticon.com/512/8765/8765164.png" alt="Import"
|
||||||
|
|
|
||||||
|
|
@ -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
|
// IMPORT / STAGING SYSTEM
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
|
||||||
|
|
@ -22977,6 +22977,273 @@ body {
|
||||||
border-bottom: 8px solid rgba(30, 30, 30, 0.98);
|
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 */
|
/* MusicBrainz Integration */
|
||||||
.release-card {
|
.release-card {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
|
|
|
||||||
BIN
webui/static/whisoul.png
Normal file
BIN
webui/static/whisoul.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 596 KiB |
Loading…
Reference in a new issue