Add Retag tool (DB, backend, frontend)

Introduce a new Retag tool to track and re-tag previously downloaded albums/singles. Changes include:

- Database: add migration hook and create retag_groups and retag_tracks tables, indexes, and many helper methods (add/find/update/delete groups & tracks, stats, trimming).
- Backend (web_server): capture completed album/single downloads into the retag tables, implement retag execution logic (_execute_retag) to fetch album metadata, match tracks, update tags, move files, download cover art, and update DB. Add thread-safe globals, executor, and REST endpoints for stats, listing groups, group tracks, album search, execute, status, and delete.
- Frontend (webui): add Retag Tool card, modals, search UI, JS to list groups/tracks, search albums, start retag operations, poll status, and update UI; include help content. Add CSS for modals and components.

The migration is invoked during DB init to ensure existing installations create the new tables. The tool caps stored groups (default 100) and avoids duplicate track entries.
pull/165/head
Broque Thomas 3 months ago
parent 81617b06aa
commit 317d5c1770

@ -325,6 +325,9 @@ class MusicDatabase:
)
""")
# Retag tool tables for tracking processed downloads (migration)
self._add_retag_tables(cursor)
conn.commit()
logger.info("Database initialized successfully")
@ -1272,6 +1275,47 @@ class MusicDatabase:
logger.error(f"Error adding Spotify/iTunes enrichment columns: {e}")
# Don't raise - this is a migration, database can still function
def _add_retag_tables(self, cursor):
"""Add retag tool tables for tracking processed downloads"""
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS retag_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_type TEXT NOT NULL DEFAULT 'album',
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
image_url TEXT,
spotify_album_id TEXT,
itunes_album_id TEXT,
total_tracks INTEGER DEFAULT 1,
release_date TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS retag_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER DEFAULT 1,
title TEXT NOT NULL,
file_path TEXT NOT NULL,
file_format TEXT,
spotify_track_id TEXT,
itunes_track_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (group_id) REFERENCES retag_groups (id) ON DELETE CASCADE
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_retag_groups_artist ON retag_groups (artist_name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_retag_tracks_group ON retag_tracks (group_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_retag_tracks_path ON retag_tracks (file_path)")
except Exception as e:
logger.error(f"Error adding retag tables: {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
@ -5005,6 +5049,196 @@ class MusicDatabase:
logger.error(f"Error saving discovery cache: {e}")
return False
# ==================== Retag Tool Methods ====================
def add_retag_group(self, group_type: str, artist_name: str, album_name: str,
image_url: str = None, spotify_album_id: str = None,
itunes_album_id: str = None, total_tracks: int = 1,
release_date: str = None) -> Optional[int]:
"""Insert a retag group and return its ID."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO retag_groups (group_type, artist_name, album_name, image_url,
spotify_album_id, itunes_album_id, total_tracks, release_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (group_type, artist_name, album_name, image_url,
spotify_album_id, itunes_album_id, total_tracks, release_date))
conn.commit()
return cursor.lastrowid
except Exception as e:
logger.error(f"Error adding retag group: {e}")
return None
def add_retag_track(self, group_id: int, track_number: int, disc_number: int,
title: str, file_path: str, file_format: str = None,
spotify_track_id: str = None, itunes_track_id: str = None) -> Optional[int]:
"""Insert a retag track record and return its ID."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT INTO retag_tracks (group_id, track_number, disc_number, title,
file_path, file_format, spotify_track_id, itunes_track_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (group_id, track_number, disc_number, title, file_path,
file_format, spotify_track_id, itunes_track_id))
conn.commit()
return cursor.lastrowid
except Exception as e:
logger.error(f"Error adding retag track: {e}")
return None
def get_retag_groups(self) -> List[Dict[str, Any]]:
"""Return all retag groups ordered by artist_name, created_at DESC."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT g.*, COUNT(t.id) as track_count
FROM retag_groups g
LEFT JOIN retag_tracks t ON t.group_id = g.id
GROUP BY g.id
ORDER BY g.artist_name ASC, g.created_at DESC
""")
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting retag groups: {e}")
return []
def get_retag_tracks(self, group_id: int) -> List[Dict[str, Any]]:
"""Return all tracks for a given group_id ordered by disc_number, track_number."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM retag_tracks
WHERE group_id = ?
ORDER BY disc_number ASC, track_number ASC
""", (group_id,))
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting retag tracks: {e}")
return []
def get_retag_stats(self) -> Dict[str, int]:
"""Return retag statistics: groups, tracks, artists counts."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM retag_groups")
groups = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM retag_tracks")
tracks = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(DISTINCT artist_name) FROM retag_groups")
artists = cursor.fetchone()[0]
return {"groups": groups, "tracks": tracks, "artists": artists}
except Exception as e:
logger.error(f"Error getting retag stats: {e}")
return {"groups": 0, "tracks": 0, "artists": 0}
def find_retag_group(self, artist_name: str, album_name: str) -> Optional[int]:
"""Find an existing retag group by artist + album name. Returns group ID or None."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id FROM retag_groups WHERE artist_name = ? AND album_name = ?",
(artist_name, album_name)
)
row = cursor.fetchone()
return row[0] if row else None
except Exception as e:
logger.error(f"Error finding retag group: {e}")
return None
def retag_track_exists(self, group_id: int, file_path: str) -> bool:
"""Check if a retag track already exists for a group + file path."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT 1 FROM retag_tracks WHERE group_id = ? AND file_path = ?",
(group_id, file_path)
)
return cursor.fetchone() is not None
except Exception as e:
logger.error(f"Error checking retag track existence: {e}")
return False
def update_retag_track_path(self, track_id: int, new_file_path: str) -> bool:
"""Update file_path for a retag track after re-tag move."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE retag_tracks SET file_path = ? WHERE id = ?",
(new_file_path, track_id)
)
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating retag track path: {e}")
return False
def update_retag_group(self, group_id: int, **kwargs) -> bool:
"""Update retag group fields. Accepts keyword args for columns to update."""
allowed = {'group_type', 'artist_name', 'album_name', 'image_url',
'spotify_album_id', 'itunes_album_id', 'total_tracks', 'release_date'}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return False
try:
conn = self._get_connection()
cursor = conn.cursor()
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [group_id]
cursor.execute(f"UPDATE retag_groups SET {set_clause} WHERE id = ?", values)
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating retag group: {e}")
return False
def trim_retag_groups(self, max_groups: int = 100):
"""Remove oldest retag groups if count exceeds max_groups."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM retag_groups")
count = cursor.fetchone()[0]
if count <= max_groups:
return
excess = count - max_groups
cursor.execute(
"SELECT id FROM retag_groups ORDER BY created_at ASC LIMIT ?", (excess,)
)
old_ids = [row[0] for row in cursor.fetchall()]
for gid in old_ids:
cursor.execute("DELETE FROM retag_tracks WHERE group_id = ?", (gid,))
cursor.execute("DELETE FROM retag_groups WHERE id = ?", (gid,))
conn.commit()
logger.info(f"Trimmed {len(old_ids)} oldest retag groups (cap: {max_groups})")
except Exception as e:
logger.error(f"Error trimming retag groups: {e}")
def delete_retag_group(self, group_id: int) -> bool:
"""Delete a retag group and its tracks (CASCADE)."""
try:
conn = self._get_connection()
cursor = conn.cursor()
# Manually delete tracks first since SQLite CASCADE requires PRAGMA foreign_keys=ON
cursor.execute("DELETE FROM retag_tracks WHERE group_id = ?", (group_id,))
cursor.execute("DELETE FROM retag_groups WHERE id = ?", (group_id,))
conn.commit()
return True
except Exception as e:
logger.error(f"Error deleting retag group: {e}")
return False
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
_database_lock = threading.Lock()

@ -258,6 +258,19 @@ duplicate_cleaner_state = {
duplicate_cleaner_lock = threading.Lock()
duplicate_cleaner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="DuplicateCleaner")
# --- Retag Tool Globals ---
retag_state = {
"status": "idle",
"phase": "Ready",
"progress": 0,
"current_track": "",
"total_tracks": 0,
"processed": 0,
"error_message": ""
}
retag_lock = threading.Lock()
retag_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="RetagWorker")
# --- Sync Page Globals ---
sync_executor = ThreadPoolExecutor(max_workers=3, thread_name_prefix="SyncWorker")
active_sync_workers = {} # Key: playlist_id, Value: Future object
@ -10323,6 +10336,14 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}")
# RETAG DATA CAPTURE: Record completed album/single downloads for retag tool
try:
if not playlist_folder_mode:
completed_path = context.get('_final_processed_path', final_path)
_record_retag_download(context, spotify_artist, album_info, completed_path)
except Exception as retag_err:
print(f"⚠️ [Post-Process] Retag data capture failed (non-fatal): {retag_err}")
# REPAIR: Register album folder for repair scanning when batch completes
try:
completed_path = context.get('_final_processed_path', final_path)
@ -10410,6 +10431,342 @@ _download_retry_attempts = {} # {context_key: {'count': N, 'first_attempt': tim
_download_retry_max = 10 # Max retries before giving up (10 seconds with 1s poll interval)
_download_retry_lock = threading.Lock()
def _record_retag_download(context, spotify_artist, album_info, final_path):
"""Record a completed download in the retag tables for later re-tagging."""
from database.music_database import get_database
db = get_database()
# Extract artist name
if isinstance(spotify_artist, dict):
artist_name = spotify_artist.get('name', 'Unknown Artist')
else:
artist_name = getattr(spotify_artist, 'name', 'Unknown Artist')
spotify_album = context.get('spotify_album', {})
original_search = context.get('original_search_result', {})
track_info = context.get('track_info', {})
is_album = album_info and album_info.get('is_album', False)
group_type = 'album' if is_album else 'single'
album_name = album_info.get('album_name', '') if album_info else (
original_search.get('spotify_clean_title', 'Unknown'))
# Determine album IDs (Spotify vs iTunes)
spotify_album_id = None
itunes_album_id = None
if spotify_album:
album_id_raw = str(spotify_album.get('id', ''))
if album_id_raw and album_id_raw.isdigit():
itunes_album_id = album_id_raw
elif album_id_raw:
spotify_album_id = album_id_raw
image_url = album_info.get('album_image_url') if album_info else None
total_tracks = spotify_album.get('total_tracks', 1) if spotify_album else 1
release_date = spotify_album.get('release_date', '') if spotify_album else ''
# Find or create group (avoid duplicating for multi-track albums)
group_id = db.find_retag_group(artist_name, album_name)
if group_id is None:
group_id = db.add_retag_group(
group_type=group_type, artist_name=artist_name, album_name=album_name,
image_url=image_url, spotify_album_id=spotify_album_id,
itunes_album_id=itunes_album_id, total_tracks=total_tracks,
release_date=release_date
)
if group_id is None:
return
# Track details
track_number = album_info.get('track_number', 1) if album_info else 1
disc_number = original_search.get('disc_number') or (
album_info.get('disc_number', 1) if album_info else 1)
title = original_search.get('spotify_clean_title') or (
album_info.get('clean_track_name', 'Unknown Track') if album_info else 'Unknown Track')
file_format = os.path.splitext(str(final_path))[1].lstrip('.').lower()
# Track IDs (Spotify vs iTunes)
spotify_track_id = None
itunes_track_id = None
if track_info and track_info.get('id'):
tid = str(track_info['id'])
if tid.isdigit():
itunes_track_id = tid
else:
spotify_track_id = tid
# Avoid duplicate track entries
if not db.retag_track_exists(group_id, str(final_path)):
db.add_retag_track(
group_id=group_id, track_number=track_number, disc_number=disc_number,
title=title, file_path=str(final_path), file_format=file_format,
spotify_track_id=spotify_track_id, itunes_track_id=itunes_track_id
)
print(f"📝 [Retag] Recorded track for retag: '{title}' in '{album_name}'")
# Cap retag groups at 100, remove oldest
db.trim_retag_groups(100)
def _execute_retag(group_id, album_id):
"""Execute a retag operation: re-tag files in a group with metadata from a new album match."""
global retag_state
from database.music_database import get_database
try:
with retag_lock:
retag_state.update({
"status": "running",
"phase": "Fetching album metadata...",
"progress": 0,
"current_track": "",
"total_tracks": 0,
"processed": 0,
"error_message": ""
})
# 1. Fetch new album metadata from Spotify/iTunes
album_data = spotify_client.get_album(album_id)
if not album_data:
raise ValueError(f"Could not fetch album data for ID: {album_id}")
album_tracks_response = spotify_client.get_album_tracks(album_id)
if not album_tracks_response:
raise ValueError(f"Could not fetch album tracks for ID: {album_id}")
album_tracks_items = album_tracks_response.get('items', [])
# Extract artist info
album_artists = album_data.get('artists', [])
new_artist = album_artists[0] if album_artists else {'name': 'Unknown Artist', 'id': ''}
# Ensure artist is a dict with expected fields
if not isinstance(new_artist, dict):
new_artist = {'name': str(new_artist), 'id': ''}
new_album_name = album_data.get('name', 'Unknown Album')
new_images = album_data.get('images', [])
new_image_url = new_images[0]['url'] if new_images else None
new_release_date = album_data.get('release_date', '')
total_tracks = album_data.get('total_tracks', len(album_tracks_items))
# Build spotify track list
spotify_tracks = []
for item in album_tracks_items:
track_artists = item.get('artists', [])
spotify_tracks.append({
'name': item.get('name', ''),
'track_number': item.get('track_number', 1),
'disc_number': item.get('disc_number', 1),
'id': item.get('id', ''),
'artists': track_artists,
'duration_ms': item.get('duration_ms', 0)
})
total_discs = max((t['disc_number'] for t in spotify_tracks), default=1)
# 2. Load existing tracks for this group
db = get_database()
existing_tracks = db.get_retag_tracks(group_id)
if not existing_tracks:
raise ValueError(f"No tracks found for retag group {group_id}")
with retag_lock:
retag_state['total_tracks'] = len(existing_tracks)
retag_state['phase'] = "Matching tracks..."
# 3. Match existing files to new tracklist
matched_pairs = []
for existing_track in existing_tracks:
best_match = None
best_score = 0
# Priority 1: Match by track number
for st in spotify_tracks:
if (st['track_number'] == existing_track.get('track_number') and
st['disc_number'] == existing_track.get('disc_number', 1)):
best_match = st
best_score = 1.0
break
# Priority 2: Match by title similarity
if not best_match:
from difflib import SequenceMatcher
existing_title = (existing_track.get('title') or '').lower().strip()
for st in spotify_tracks:
st_title = (st.get('name') or '').lower().strip()
score = SequenceMatcher(None, existing_title, st_title).ratio()
if score > best_score and score > 0.6:
best_score = score
best_match = st
if best_match:
matched_pairs.append((existing_track, best_match))
else:
print(f"⚠️ [Retag] No match found for track: '{existing_track.get('title')}'")
matched_pairs.append((existing_track, None))
with retag_lock:
retag_state['phase'] = "Retagging files..."
# 4. Retag each matched track
for existing_track, matched_spotify in matched_pairs:
current_file_path = existing_track.get('file_path', '')
track_title = matched_spotify['name'] if matched_spotify else existing_track.get('title', 'Unknown')
with retag_lock:
retag_state['current_track'] = track_title
if not matched_spotify:
with retag_lock:
retag_state['processed'] += 1
retag_state['progress'] = int(retag_state['processed'] / retag_state['total_tracks'] * 100)
continue
# Verify file exists
if not os.path.exists(current_file_path):
print(f"⚠️ [Retag] File not found, skipping: {current_file_path}")
with retag_lock:
retag_state['processed'] += 1
retag_state['progress'] = int(retag_state['processed'] / retag_state['total_tracks'] * 100)
continue
# Build synthetic context for _enhance_file_metadata
track_artists = matched_spotify.get('artists', [])
context = {
'original_search_result': {
'spotify_clean_title': matched_spotify['name'],
'spotify_clean_album': new_album_name,
'track_number': matched_spotify['track_number'],
'disc_number': matched_spotify.get('disc_number', 1),
'artists': track_artists,
'title': matched_spotify['name']
},
'spotify_album': {
'id': album_id,
'name': new_album_name,
'release_date': new_release_date,
'total_tracks': total_tracks,
'image_url': new_image_url,
'total_discs': total_discs
},
'track_info': {'id': matched_spotify['id']},
'spotify_artist': new_artist,
'_audio_quality': _get_audio_quality_string(current_file_path) or ''
}
album_info = {
'is_album': total_tracks > 1,
'album_name': new_album_name,
'track_number': matched_spotify['track_number'],
'disc_number': matched_spotify.get('disc_number', 1),
'clean_track_name': matched_spotify['name'],
'album_image_url': new_image_url
}
# Re-write metadata tags
try:
_enhance_file_metadata(current_file_path, context, new_artist, album_info)
print(f"✅ [Retag] Re-tagged: '{track_title}'")
except Exception as meta_err:
print(f"⚠️ [Retag] Metadata write failed for '{track_title}': {meta_err}")
# Compute new path and move if different
file_ext = os.path.splitext(current_file_path)[1]
try:
new_path, _ = _build_final_path_for_track(context, new_artist, album_info, file_ext)
if os.path.normpath(current_file_path) != os.path.normpath(new_path):
print(f"🚚 [Retag] Moving '{os.path.basename(current_file_path)}' -> '{new_path}'")
old_dir = os.path.dirname(current_file_path)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
_safe_move_file(current_file_path, new_path)
# Move .lrc file alongside audio file if it exists
old_lrc = os.path.splitext(current_file_path)[0] + '.lrc'
if os.path.exists(old_lrc):
new_lrc = os.path.splitext(new_path)[0] + '.lrc'
try:
_safe_move_file(old_lrc, new_lrc)
print(f"📝 [Retag] Moved .lrc file alongside audio")
except Exception as lrc_err:
print(f"⚠️ [Retag] Failed to move .lrc file: {lrc_err}")
# Remove old cover.jpg if directory changed and old dir is now empty of audio
new_dir = os.path.dirname(new_path)
if os.path.normpath(old_dir) != os.path.normpath(new_dir):
old_cover = os.path.join(old_dir, 'cover.jpg')
if os.path.exists(old_cover):
# Check if any audio files remain in old directory
audio_exts = {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac'}
remaining_audio = [f for f in os.listdir(old_dir)
if os.path.splitext(f)[1].lower() in audio_exts]
if not remaining_audio:
try:
os.remove(old_cover)
print(f"🗑️ [Retag] Removed orphaned cover.jpg from old directory")
except Exception:
pass
# Cleanup old empty directories
transfer_dir = docker_resolve_path(config_manager.get('soulseek.transfer_path', './Transfer'))
_cleanup_empty_directories(transfer_dir, current_file_path)
# Update DB record
db.update_retag_track_path(existing_track['id'], str(new_path))
current_file_path = new_path
else:
print(f"📍 [Retag] Path unchanged for '{track_title}', no move needed")
except Exception as move_err:
print(f"⚠️ [Retag] Path/move failed for '{track_title}': {move_err}")
# Download cover art to album directory
try:
_download_cover_art(album_info, os.path.dirname(current_file_path))
except Exception as cover_err:
print(f"⚠️ [Retag] Cover art download failed: {cover_err}")
with retag_lock:
retag_state['processed'] += 1
retag_state['progress'] = int(retag_state['processed'] / retag_state['total_tracks'] * 100)
# 5. Update the retag group record with new metadata
update_kwargs = {
'artist_name': new_artist.get('name', 'Unknown Artist'),
'album_name': new_album_name,
'image_url': new_image_url,
'total_tracks': total_tracks,
'release_date': new_release_date
}
# Set the correct ID field based on Spotify vs iTunes
if str(album_id).isdigit():
update_kwargs['itunes_album_id'] = album_id
update_kwargs['spotify_album_id'] = None
else:
update_kwargs['spotify_album_id'] = album_id
update_kwargs['itunes_album_id'] = None
db.update_retag_group(group_id, **update_kwargs)
with retag_lock:
retag_state.update({
"status": "finished",
"phase": "Retag complete!",
"progress": 100,
"current_track": ""
})
print(f"✅ [Retag] Retag operation complete for group {group_id}")
except Exception as e:
import traceback
print(f"❌ [Retag] Error during retag: {e}")
print(traceback.format_exc())
with retag_lock:
retag_state.update({
"status": "error",
"phase": "Error",
"error_message": str(e)
})
def _check_and_remove_from_wishlist(context):
"""
Check if a successfully downloaded track should be removed from wishlist.
@ -10785,7 +11142,7 @@ def start_simple_background_monitor():
def _sanitize_track_data_for_processing(track_data):
"""
Sanitizes track data from wishlist service to ensure consistent format.
Handles album field conversion from dict to string and artist field normalization.
Preserves album dict to retain full metadata (images, id, etc.) and normalizes artist field.
"""
if not isinstance(track_data, dict):
print(f"⚠️ [Sanitize] Unexpected track data type: {type(track_data)}")
@ -10794,11 +11151,10 @@ def _sanitize_track_data_for_processing(track_data):
# Create a copy to avoid modifying original data
sanitized = track_data.copy()
# Handle album field - convert dictionary to string if needed
# Handle album field - preserve dict format to retain full metadata (images, id, etc.)
# Downstream code already handles both dict and string formats defensively
raw_album = sanitized.get('album', '')
if isinstance(raw_album, dict) and 'name' in raw_album:
sanitized['album'] = raw_album['name']
elif not isinstance(raw_album, str):
if not isinstance(raw_album, (dict, str)):
sanitized['album'] = str(raw_album)
# Handle artists field - ensure it's a list of strings
@ -12914,6 +13270,96 @@ def stop_duplicate_cleaner():
else:
return jsonify({"success": False, "error": "No scan is currently running"}), 404
# ===============================
# == RETAG TOOL ENDPOINTS ==
# ===============================
@app.route('/api/retag/stats', methods=['GET'])
def get_retag_stats():
"""Get retag tool statistics for the dashboard card."""
from database.music_database import get_database
db = get_database()
stats = db.get_retag_stats()
return jsonify({"success": True, **stats})
@app.route('/api/retag/groups', methods=['GET'])
def get_retag_groups():
"""Get all retag groups sorted by artist name."""
from database.music_database import get_database
db = get_database()
groups = db.get_retag_groups()
return jsonify({"success": True, "groups": groups})
@app.route('/api/retag/groups/<int:group_id>/tracks', methods=['GET'])
def get_retag_group_tracks(group_id):
"""Get tracks for a specific retag group."""
from database.music_database import get_database
db = get_database()
tracks = db.get_retag_tracks(group_id)
return jsonify({"success": True, "tracks": tracks})
@app.route('/api/retag/search', methods=['GET'])
def search_retag_albums():
"""Search for albums to use for retagging (uses Spotify/iTunes fallback)."""
query = request.args.get('q', '').strip()
if not query:
return jsonify({"success": False, "error": "Query parameter 'q' is required"}), 400
limit = min(int(request.args.get('limit', 12)), 50)
try:
results = spotify_client.search_albums(query, limit=limit)
albums = []
for a in results:
albums.append({
'id': str(a.id),
'name': a.name,
'artist': ', '.join(a.artists) if a.artists else 'Unknown Artist',
'release_date': a.release_date or '',
'total_tracks': a.total_tracks,
'image_url': a.image_url,
'album_type': a.album_type or 'album'
})
return jsonify({"success": True, "albums": albums})
except Exception as e:
print(f"❌ [Retag] Album search error: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/retag/execute', methods=['POST'])
def execute_retag():
"""Start a retag operation for a group with a new album match."""
data = request.get_json()
if not data:
return jsonify({"success": False, "error": "JSON body required"}), 400
group_id = data.get('group_id')
album_id = data.get('album_id')
if not group_id or not album_id:
return jsonify({"success": False, "error": "group_id and album_id are required"}), 400
with retag_lock:
if retag_state["status"] == "running":
return jsonify({"success": False, "error": "A retag operation is already running"}), 409
retag_executor.submit(_execute_retag, group_id, str(album_id))
return jsonify({"success": True, "message": "Retag operation started"})
@app.route('/api/retag/status', methods=['GET'])
def get_retag_status():
"""Get the current retag operation status."""
with retag_lock:
return jsonify(dict(retag_state))
@app.route('/api/retag/groups/<int:group_id>', methods=['DELETE'])
def delete_retag_group(group_id):
"""Delete a retag group (files are NOT deleted)."""
from database.music_database import get_database
db = get_database()
success = db.delete_retag_group(group_id)
if success:
return jsonify({"success": True})
else:
return jsonify({"success": False, "error": "Group not found"}), 404
# ===============================
# == DOWNLOAD MISSING TRACKS ==
# ===============================
@ -14265,7 +14711,13 @@ def _run_post_processing_worker(task_id, batch_id):
# name than the stream processor (e.g. raw API name vs resolved name),
# causing media servers to split tracks into separate albums.
try:
original_album_ctx = original_search.get('album') if isinstance(original_search.get('album'), str) else None
raw_album_ctx = original_search.get('album')
if isinstance(raw_album_ctx, str):
original_album_ctx = raw_album_ctx
elif isinstance(raw_album_ctx, dict) and 'name' in raw_album_ctx:
original_album_ctx = raw_album_ctx['name']
else:
original_album_ctx = None
consistent_album_name = _resolve_album_group(spotify_artist, album_info, original_album_ctx)
album_info['album_name'] = consistent_album_name
except Exception as group_err:

@ -584,6 +584,44 @@
</div>
</div>
<div class="tool-card" id="retag-tool-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Retag Tool</h4>
<button class="tool-help-button" data-tool="retag-tool"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Fix metadata on previously downloaded albums &amp; singles</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">Groups:</span>
<span class="stat-item-value" id="retag-stat-groups">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Tracks:</span>
<span class="stat-item-value" id="retag-stat-tracks">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Artists:</span>
<span class="stat-item-value" id="retag-stat-artists">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Status:</span>
<span class="stat-item-value" id="retag-stat-status">Idle</span>
</div>
</div>
<div class="tool-card-controls">
<button id="retag-open-button">Open Retag Tool</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="retag-phase-label">Ready</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="retag-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="retag-progress-label">0 / 0 tracks (0.0%)</p>
</div>
</div>
<div class="tool-card" id="media-scan-card" style="display: none;">
<div class="tool-card-header">
<h4 class="tool-card-title">Media Server Scan</h4>
@ -3925,6 +3963,33 @@
</div>
<!-- Tool Help Modal -->
<!-- Retag Tool Modal -->
<div class="retag-modal-overlay" id="retag-modal">
<div class="retag-modal-container">
<div class="retag-modal-header">
<h2 class="retag-modal-title">Retag Tool</h2>
<button class="retag-modal-close" onclick="closeRetagModal()">&times;</button>
</div>
<div class="retag-modal-body" id="retag-modal-body">
<div class="retag-loading">Loading downloads...</div>
</div>
</div>
</div>
<!-- Retag Search Sub-Modal -->
<div class="retag-search-overlay" id="retag-search-modal">
<div class="retag-search-container">
<div class="retag-search-header">
<h3 class="retag-search-title" id="retag-search-title">Search for Correct Album</h3>
<button class="retag-search-close" onclick="closeRetagSearch()">&times;</button>
</div>
<div class="retag-search-input-section">
<input type="text" id="retag-search-input" placeholder="Search albums..." autocomplete="off">
</div>
<div class="retag-search-results" id="retag-search-results"></div>
</div>
</div>
<div class="tool-help-modal" id="tool-help-modal">
<div class="tool-help-modal-content">
<div class="tool-help-modal-header">

@ -13558,6 +13558,42 @@ const TOOL_HELP_CONTENT = {
<p>This tool replicates the same scan process that runs automatically after completing a download modal - ensuring your new tracks are immediately available in your library!</p>
`
},
'retag-tool': {
title: 'Retag Tool',
content: `
<h4>What does this tool do?</h4>
<p>The Retag Tool lets you fix metadata on files that have already been downloaded and processed. If an album was tagged with wrong metadata, you can search for the correct match and re-apply tags.</p>
<h4>How it works</h4>
<ul>
<li>Browse your past downloads organized by artist</li>
<li>Expand an album or single to see individual tracks</li>
<li>Click <strong>Retag</strong> to search for the correct album match</li>
<li>Select the right album and confirm &mdash; metadata and file paths are updated automatically</li>
</ul>
<h4>What gets updated?</h4>
<ul>
<li><strong>File tags:</strong> Title, artist, album, track number, genre, cover art</li>
<li><strong>File paths:</strong> Files are moved/renamed to match new metadata (based on your path template)</li>
<li><strong>Cover art:</strong> cover.jpg is updated in the album folder</li>
</ul>
<h4>Stats Explained</h4>
<ul>
<li><strong>Groups:</strong> Number of album/single download groups tracked</li>
<li><strong>Tracks:</strong> Total individual track files tracked</li>
<li><strong>Artists:</strong> Number of unique artists across all groups</li>
</ul>
<h4>Notes</h4>
<ul>
<li>Only album and single downloads are tracked (not playlists)</li>
<li>Deleting a group from the list does <strong>not</strong> delete the files</li>
<li>Only one retag operation can run at a time</li>
</ul>
`
},
'discover-page': {
title: 'Discover Page Guide',
content: `
@ -13778,6 +13814,340 @@ function closeToolHelpModal() {
document.body.style.overflow = ''; // Restore scrolling
}
// ===============================
// == RETAG TOOL FUNCTIONS ==
// ===============================
let retagStatusInterval = null;
let retagCurrentGroupId = null;
async function loadRetagStats() {
try {
const response = await fetch('/api/retag/stats');
const data = await response.json();
if (data.success !== false) {
const groupsEl = document.getElementById('retag-stat-groups');
const tracksEl = document.getElementById('retag-stat-tracks');
const artistsEl = document.getElementById('retag-stat-artists');
if (groupsEl) groupsEl.textContent = data.groups || 0;
if (tracksEl) tracksEl.textContent = data.tracks || 0;
if (artistsEl) artistsEl.textContent = data.artists || 0;
}
} catch (e) {
console.warn('Failed to load retag stats:', e);
}
}
async function openRetagModal() {
const modal = document.getElementById('retag-modal');
if (!modal) return;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
const body = document.getElementById('retag-modal-body');
body.innerHTML = '<div class="retag-loading">Loading downloads...</div>';
try {
const response = await fetch('/api/retag/groups');
const data = await response.json();
if (!data.success || !data.groups || data.groups.length === 0) {
body.innerHTML = '<p class="retag-empty">No downloads recorded yet. Downloads will appear here after completing album or single downloads.</p>';
return;
}
renderRetagGroups(data.groups, body);
} catch (e) {
body.innerHTML = '<p class="retag-error">Failed to load downloads.</p>';
}
}
function closeRetagModal() {
const modal = document.getElementById('retag-modal');
if (modal) modal.style.display = 'none';
document.body.style.overflow = '';
}
function renderRetagGroups(groups, container) {
// Group by artist_name
const byArtist = {};
groups.forEach(g => {
const artist = g.artist_name || 'Unknown Artist';
if (!byArtist[artist]) byArtist[artist] = [];
byArtist[artist].push(g);
});
let html = '';
Object.keys(byArtist).sort((a, b) => a.localeCompare(b)).forEach(artist => {
html += `<div class="retag-artist-section">
<h3 class="retag-artist-name">${escapeHtml(artist)}</h3>
<div class="retag-artist-groups">`;
byArtist[artist].forEach(group => {
const imgHtml = group.image_url
? `<img class="retag-group-image" src="${group.image_url}" alt="" loading="lazy">`
: '<div class="retag-group-image-placeholder"></div>';
const trackCount = group.track_count || group.total_tracks || 0;
const typeLabel = (group.group_type || 'album').charAt(0).toUpperCase() + (group.group_type || 'album').slice(1);
const releaseDate = group.release_date ? group.release_date.substring(0, 4) : '';
const defaultQuery = (artist + ' ' + (group.album_name || '')).trim();
html += `<div class="retag-group-card" data-group-id="${group.id}">
<div class="retag-group-header" onclick="toggleRetagGroup(${group.id})">
${imgHtml}
<div class="retag-group-info">
<span class="retag-group-album">${escapeHtml(group.album_name || 'Unknown')}</span>
<span class="retag-group-meta">${typeLabel}${releaseDate ? ' \u00b7 ' + releaseDate : ''} \u00b7 ${trackCount} track${trackCount !== 1 ? 's' : ''}</span>
</div>
<button class="retag-group-btn" onclick="event.stopPropagation(); openRetagSearch(${group.id}, '${escapeHtml(defaultQuery).replace(/'/g, "\\'")}')" title="Re-tag with different album">Retag</button>
<button class="retag-group-delete-btn" onclick="event.stopPropagation(); deleteRetagGroup(${group.id})" title="Remove from list">&times;</button>
</div>
<div class="retag-group-tracks" id="retag-tracks-${group.id}" style="display:none;">
<div class="retag-tracks-loading">Loading tracks...</div>
</div>
</div>`;
});
html += `</div></div>`;
});
container.innerHTML = html;
}
async function toggleRetagGroup(groupId) {
const tracksDiv = document.getElementById(`retag-tracks-${groupId}`);
if (!tracksDiv) return;
if (tracksDiv.style.display === 'none') {
tracksDiv.style.display = 'block';
if (tracksDiv.querySelector('.retag-tracks-loading')) {
try {
const response = await fetch(`/api/retag/groups/${groupId}/tracks`);
const data = await response.json();
if (data.success && data.tracks && data.tracks.length > 0) {
tracksDiv.innerHTML = data.tracks.map(t => {
const discPrefix = t.disc_number > 1 ? `${t.disc_number}-` : '';
const trackNum = t.track_number != null ? `${discPrefix}${String(t.track_number).padStart(2, '0')}` : '--';
return `<div class="retag-track-item">
<span class="retag-track-number">${trackNum}</span>
<span class="retag-track-title">${escapeHtml(t.title || 'Unknown')}</span>
<span class="retag-track-format">${(t.file_format || '').toUpperCase()}</span>
</div>`;
}).join('');
} else {
tracksDiv.innerHTML = '<p class="retag-tracks-empty">No tracks found</p>';
}
} catch (e) {
tracksDiv.innerHTML = '<p class="retag-tracks-empty">Failed to load tracks</p>';
}
}
} else {
tracksDiv.style.display = 'none';
}
}
function openRetagSearch(groupId, defaultQuery) {
retagCurrentGroupId = groupId;
const modal = document.getElementById('retag-search-modal');
if (!modal) return;
modal.style.display = 'flex';
const input = document.getElementById('retag-search-input');
if (input) {
input.value = defaultQuery || '';
input.focus();
if (defaultQuery) {
searchRetagAlbums(defaultQuery);
}
}
}
function closeRetagSearch() {
const modal = document.getElementById('retag-search-modal');
if (modal) modal.style.display = 'none';
retagCurrentGroupId = null;
}
let retagSearchTimeout = null;
document.addEventListener('DOMContentLoaded', () => {
const retagSearchInput = document.getElementById('retag-search-input');
if (retagSearchInput) {
retagSearchInput.addEventListener('input', (e) => {
clearTimeout(retagSearchTimeout);
retagSearchTimeout = setTimeout(() => searchRetagAlbums(e.target.value), 400);
});
retagSearchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(retagSearchTimeout);
searchRetagAlbums(e.target.value);
}
});
}
// Close retag modals on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const searchModal = document.getElementById('retag-search-modal');
if (searchModal && searchModal.style.display === 'flex') {
closeRetagSearch();
return;
}
const mainModal = document.getElementById('retag-modal');
if (mainModal && mainModal.style.display === 'flex') {
closeRetagModal();
}
}
});
// Close retag modal on overlay click
const retagModal = document.getElementById('retag-modal');
if (retagModal) {
retagModal.addEventListener('click', (e) => {
if (e.target === retagModal) closeRetagModal();
});
}
const retagSearchModal = document.getElementById('retag-search-modal');
if (retagSearchModal) {
retagSearchModal.addEventListener('click', (e) => {
if (e.target === retagSearchModal) closeRetagSearch();
});
}
});
async function searchRetagAlbums(query) {
if (!query || !query.trim()) return;
const resultsDiv = document.getElementById('retag-search-results');
if (!resultsDiv) return;
resultsDiv.innerHTML = '<div class="retag-search-loading">Searching...</div>';
try {
const response = await fetch(`/api/retag/search?q=${encodeURIComponent(query.trim())}`);
const data = await response.json();
if (data.success && data.albums && data.albums.length > 0) {
resultsDiv.innerHTML = data.albums.map(a => {
const imgHtml = a.image_url
? `<img class="retag-result-image" src="${a.image_url}" alt="" loading="lazy">`
: '<div class="retag-result-image-placeholder"></div>';
const typeLabel = (a.album_type || 'album').charAt(0).toUpperCase() + (a.album_type || 'album').slice(1);
const releaseYear = a.release_date ? a.release_date.substring(0, 4) : '';
return `<div class="retag-search-result" onclick="confirmRetag(${retagCurrentGroupId}, '${a.id}', '${escapeHtml(a.name).replace(/'/g, "\\'")}')">
${imgHtml}
<div class="retag-result-info">
<span class="retag-result-name">${escapeHtml(a.name || 'Unknown')}</span>
<span class="retag-result-artist">${escapeHtml(a.artist || 'Unknown')}</span>
<span class="retag-result-meta">${typeLabel}${releaseYear ? ' \u00b7 ' + releaseYear : ''} \u00b7 ${a.total_tracks || 0} tracks</span>
</div>
</div>`;
}).join('');
} else {
resultsDiv.innerHTML = '<p class="retag-no-results">No albums found.</p>';
}
} catch (e) {
resultsDiv.innerHTML = '<p class="retag-search-error">Search failed.</p>';
}
}
async function confirmRetag(groupId, albumId, albumName) {
if (!confirm(`Re-tag this group with "${albumName}"?\n\nThis will overwrite existing file tags and may move/rename files.`)) return;
closeRetagSearch();
closeRetagModal();
try {
const response = await fetch('/api/retag/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_id: groupId, album_id: albumId })
});
const data = await response.json();
if (data.success) {
showToast('Retag operation started', 'success');
startRetagPolling();
} else {
showToast(`Error: ${data.error || 'Unknown error'}`, 'error');
}
} catch (e) {
showToast('Failed to start retag operation', 'error');
}
}
function startRetagPolling() {
if (retagStatusInterval) return;
retagStatusInterval = setInterval(checkRetagStatus, 1000);
checkRetagStatus();
}
async function checkRetagStatus() {
try {
const response = await fetch('/api/retag/status');
const state = await response.json();
updateRetagProgressUI(state);
if (state.status === 'running' && !retagStatusInterval) {
startRetagPolling();
}
if (state.status !== 'running' && retagStatusInterval) {
clearInterval(retagStatusInterval);
retagStatusInterval = null;
if (state.status === 'finished') {
showToast('Retag completed successfully', 'success');
loadRetagStats();
} else if (state.status === 'error') {
showToast(`Retag error: ${state.error_message || 'Unknown error'}`, 'error');
}
}
} catch (e) {
// Ignore fetch errors during polling
}
}
function updateRetagProgressUI(state) {
const phaseLabel = document.getElementById('retag-phase-label');
const progressBar = document.getElementById('retag-progress-bar');
const progressLabel = document.getElementById('retag-progress-label');
const statusEl = document.getElementById('retag-stat-status');
if (phaseLabel) phaseLabel.textContent = state.phase || 'Ready';
if (progressBar) progressBar.style.width = `${state.progress || 0}%`;
if (progressLabel) {
progressLabel.textContent = `${state.processed || 0} / ${state.total_tracks || 0} tracks (${(state.progress || 0).toFixed(1)}%)`;
}
if (statusEl) {
statusEl.textContent = state.status === 'running' ? 'Running' : 'Idle';
}
// Color the progress bar red on error
if (progressBar) {
progressBar.style.backgroundColor = state.status === 'error' ? '#ff4444' : '';
}
}
async function deleteRetagGroup(groupId) {
if (!confirm('Remove this group from the retag list?\n\nYour files will not be deleted.')) return;
try {
const response = await fetch(`/api/retag/groups/${groupId}`, { method: 'DELETE' });
const data = await response.json();
if (data.success) {
// Remove the card from DOM
const card = document.querySelector(`.retag-group-card[data-group-id="${groupId}"]`);
if (card) {
const section = card.closest('.retag-artist-section');
card.remove();
// If no more groups for this artist, remove the artist section
if (section && section.querySelectorAll('.retag-group-card').length === 0) {
section.remove();
}
}
loadRetagStats();
showToast('Group removed from retag list', 'success');
} else {
showToast('Failed to remove group', 'error');
}
} catch (e) {
showToast('Failed to remove group', 'error');
}
}
function stopWishlistCountPolling() {
if (wishlistCountInterval) {
clearInterval(wishlistCountInterval);
@ -13889,6 +14259,12 @@ async function loadDashboardData() {
duplicateCleanButton.addEventListener('click', handleDuplicateCleanButtonClick);
}
// Attach event listener for the retag tool
const retagOpenButton = document.getElementById('retag-open-button');
if (retagOpenButton) {
retagOpenButton.addEventListener('click', openRetagModal);
}
// Attach event listener for the media scan tool
const mediaScanButton = document.getElementById('media-scan-button');
if (mediaScanButton) {
@ -13907,6 +14283,12 @@ async function loadDashboardData() {
wishlistButton.addEventListener('click', handleWishlistButtonClick);
}
// Initial load of retag stats
loadRetagStats();
// Check for ongoing retag operation
checkRetagStatus();
// Initial load of stats
await fetchAndUpdateDbStats();

@ -18548,6 +18548,387 @@ body {
color: #fff;
}
/* ====================================
Retag Tool Modal Styles
==================================== */
.retag-modal-overlay,
.retag-search-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 10001;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.retag-search-overlay {
z-index: 10002;
}
.retag-modal-container {
background-color: #1a1a1a;
border-radius: 12px;
max-width: 700px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s ease;
}
.retag-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.retag-modal-title {
font-size: 20px;
font-weight: 700;
color: #fff;
margin: 0;
}
.retag-modal-close {
background: none;
border: none;
color: #999;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s;
}
.retag-modal-close:hover {
color: #fff;
}
.retag-modal-body {
padding: 20px 24px;
overflow-y: auto;
flex: 1;
}
.retag-loading,
.retag-empty,
.retag-error {
text-align: center;
color: #b3b3b3;
padding: 40px 0;
font-size: 14px;
}
/* Artist sections */
.retag-artist-section {
margin-bottom: 20px;
}
.retag-artist-name {
font-size: 14px;
font-weight: 600;
color: #b3b3b3;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 10px 0;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* Group cards */
.retag-group-card {
background: rgba(30, 30, 30, 0.8);
border-radius: 10px;
margin-bottom: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
transition: border-color 0.2s;
}
.retag-group-card:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.retag-group-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
cursor: pointer;
}
.retag-group-image {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.retag-group-image-placeholder {
width: 48px;
height: 48px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.retag-group-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.retag-group-album {
font-weight: 600;
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.retag-group-meta {
font-size: 12px;
color: #b3b3b3;
}
.retag-group-btn {
background: #1db954;
color: #fff;
border: none;
padding: 6px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
transition: background 0.2s, transform 0.1s;
}
.retag-group-btn:hover {
background: #1ed760;
transform: scale(1.05);
}
.retag-group-delete-btn {
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
flex-shrink: 0;
transition: color 0.2s;
}
.retag-group-delete-btn:hover {
color: #ff4444;
}
/* Track list */
.retag-group-tracks {
padding: 0 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.retag-track-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.retag-track-item:last-child {
border-bottom: none;
}
.retag-track-number {
color: #666;
font-size: 12px;
width: 32px;
text-align: right;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.retag-track-title {
flex: 1;
color: #ddd;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.retag-track-format {
color: #1db954;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.retag-tracks-loading,
.retag-tracks-empty {
color: #666;
font-size: 12px;
padding: 8px 0;
text-align: center;
margin: 0;
}
/* Search sub-modal */
.retag-search-container {
background-color: #1a1a1a;
border-radius: 12px;
max-width: 600px;
width: 90%;
max-height: 75vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
animation: slideUp 0.3s ease;
}
.retag-search-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.retag-search-title {
font-size: 16px;
font-weight: 700;
color: #fff;
margin: 0;
}
.retag-search-close {
background: none;
border: none;
color: #999;
font-size: 22px;
cursor: pointer;
padding: 0;
line-height: 1;
transition: color 0.2s;
}
.retag-search-close:hover {
color: #fff;
}
.retag-search-input-section {
padding: 16px 20px 8px;
flex-shrink: 0;
}
.retag-search-input-section input {
width: 100%;
padding: 10px 14px;
background: #2a2a2a;
border: 1px solid #404040;
border-radius: 8px;
color: #fff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.retag-search-input-section input:focus {
border-color: #1db954;
}
.retag-search-results {
padding: 8px 20px 20px;
overflow-y: auto;
flex: 1;
}
.retag-search-loading,
.retag-no-results,
.retag-search-error {
text-align: center;
color: #b3b3b3;
padding: 20px 0;
font-size: 13px;
}
.retag-search-result {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
}
.retag-search-result:hover {
background: rgba(29, 185, 84, 0.1);
}
.retag-result-image {
width: 56px;
height: 56px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.retag-result-image-placeholder {
width: 56px;
height: 56px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.retag-result-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.retag-result-name {
font-weight: 600;
font-size: 14px;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.retag-result-artist {
font-size: 13px;
color: #b3b3b3;
}
.retag-result-meta {
font-size: 12px;
color: #666;
}
/* ====================================
Discover Page Styles
==================================== */

Loading…
Cancel
Save