From 317d5c17709c4e52ba61b6dcd4127261deeb9427 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Mon, 23 Feb 2026 20:39:04 -0800 Subject: [PATCH] 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. --- database/music_database.py | 234 +++++++++++++++++++ web_server.py | 464 ++++++++++++++++++++++++++++++++++++- webui/index.html | 65 ++++++ webui/static/script.js | 382 ++++++++++++++++++++++++++++++ webui/static/style.css | 381 ++++++++++++++++++++++++++++++ 5 files changed, 1520 insertions(+), 6 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 4fcda85a..ddda3a06 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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() diff --git a/web_server.py b/web_server.py index a7f30f88..0d3a6d48 100644 --- a/web_server.py +++ b/web_server.py @@ -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//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/', 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: diff --git a/webui/index.html b/webui/index.html index 353a2d58..b06069fb 100644 --- a/webui/index.html +++ b/webui/index.html @@ -584,6 +584,44 @@ +
+
+

Retag Tool

+ +
+

Fix metadata on previously downloaded albums & singles

+
+
+ Groups: + 0 +
+
+ Tracks: + 0 +
+
+ Artists: + 0 +
+
+ Status: + Idle +
+
+
+ +
+
+

Ready

+
+
+
+
+

0 / 0 tracks (0.0%)

+
+
+