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%)

+
+
+