From b395e33820e8c5015e1fc547d10818b43adeeee9 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:27:33 -0700 Subject: [PATCH] Lift redownload_start to core/library/redownload.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Body byte-identical to the original. Spotify proxy via registry, iTunes/Deezer client shims wrap registry helpers, _resolve_library_file_path, _attempt_download_with_candidates, and missing_download_executor are injected via init() right after _init_wishlist_failed where all three deps are already defined. web_server.py: 35239 → 35063 (-176 lines). --- core/library/redownload.py | 259 +++++++++++++++++++++++++++++++++++++ web_server.py | 202 ++--------------------------- 2 files changed, 272 insertions(+), 189 deletions(-) create mode 100644 core/library/redownload.py diff --git a/core/library/redownload.py b/core/library/redownload.py new file mode 100644 index 00000000..cf1bb182 --- /dev/null +++ b/core/library/redownload.py @@ -0,0 +1,259 @@ +"""Track redownload endpoint — lifted from web_server.py. + +Body is byte-identical to the original. The ``spotify_client`` proxy ++ helper shims for the iTunes/Deezer registry clients let the body +resolve its original names; ``_resolve_library_file_path``, +``_attempt_download_with_candidates``, and ``missing_download_executor`` +are injected via init() because they live in web_server.py. +""" +import logging +import time + +from flask import jsonify, request + +from core.runtime_state import ( + download_batches, + download_tasks, + tasks_lock, +) +from core.metadata.registry import ( + get_deezer_client, + get_itunes_client, + get_spotify_client, +) +from database.music_database import get_database + +logger = logging.getLogger(__name__) + + +def _get_itunes_client(): + """Mirror of web_server._get_itunes_client — delegates to registry.""" + return get_itunes_client() + + +def _get_deezer_client(): + """Mirror of web_server._get_deezer_client — delegates to registry.""" + return get_deezer_client() + + +class _SpotifyClientProxy: + """Resolves the global Spotify client lazily through core.metadata.registry.""" + + def __getattr__(self, name): + client = get_spotify_client() + if client is None: + raise AttributeError(name) + return getattr(client, name) + + def __bool__(self): + return get_spotify_client() is not None + + +spotify_client = _SpotifyClientProxy() + + +# Injected at runtime via init(). +_resolve_library_file_path = None +_attempt_download_with_candidates = None +missing_download_executor = None + + +def init(resolve_library_file_path_fn, attempt_download_with_candidates_fn, executor): + """Bind shared helpers from web_server.""" + global _resolve_library_file_path, _attempt_download_with_candidates + global missing_download_executor + _resolve_library_file_path = resolve_library_file_path_fn + _attempt_download_with_candidates = attempt_download_with_candidates_fn + missing_download_executor = executor + + +def redownload_start(track_id): + """Start downloading a specific track from a selected source to replace the current file.""" + try: + data = request.get_json() + metadata = data.get('metadata', {}) + candidate = data.get('candidate', {}) + delete_old = data.get('delete_old_file', True) + + if not candidate.get('username') or not candidate.get('filename'): + return jsonify({"success": False, "error": "candidate with username and filename required"}), 400 + + # Get current track info for old file path + database = get_database() + conn = database._get_connection() + cursor = conn.cursor() + cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (track_id,)) + row = cursor.fetchone() + conn.close() + + old_file_path = None + if row and row['file_path'] and delete_old: + old_file_path = _resolve_library_file_path(row['file_path']) + + task_id = f"redownload_{track_id}_{int(time.time())}" + batch_id = f"redownload_batch_{track_id}" + + # Fetch full track details from the metadata source for pipeline parity + # This gives us track_number, disc_number, full album data + meta_source = metadata.get('_source', '') + meta_id = metadata.get('id', '') + full_track_details = None + full_album_data = None + + if meta_id: + try: + if meta_source == 'spotify' and spotify_client and spotify_client.is_authenticated(): + full_track_details = spotify_client.get_track_details(meta_id) + if full_track_details and full_track_details.get('album', {}).get('id'): + full_album_data = spotify_client.get_album(full_track_details['album']['id']) + elif meta_source == 'itunes': + _it = _get_itunes_client() + results = _it._lookup(id=meta_id, entity='song') + if results: + for r in results: + if r.get('wrapperType') == 'track': + full_track_details = r + break + elif meta_source == 'deezer': + _dz = _get_deezer_client() + full_track_details = _dz._api_get(f'track/{meta_id}') + except Exception as e: + logger.debug(f"[Redownload] Could not fetch full track details: {e}") + + # Build track data with full metadata for pipeline parity + track_number = None + disc_number = 1 + album_data = {'name': metadata.get('album', '')} + + if full_track_details: + if meta_source == 'spotify': + track_number = full_track_details.get('track_number') + disc_number = full_track_details.get('disc_number', 1) + album_raw = full_track_details.get('album', {}) + if album_raw: + album_images = album_raw.get('images', []) + album_data = { + 'id': album_raw.get('id', ''), + 'name': album_raw.get('name', metadata.get('album', '')), + 'release_date': album_raw.get('release_date', ''), + 'album_type': album_raw.get('album_type', 'album'), + 'total_tracks': album_raw.get('total_tracks', 0), + 'images': album_images, + 'image_url': album_images[0]['url'] if album_images else '', + } + elif meta_source == 'itunes': + track_number = full_track_details.get('trackNumber') + disc_number = full_track_details.get('discNumber', 1) + elif meta_source == 'deezer': + track_number = full_track_details.get('track_position') + disc_number = full_track_details.get('disk_number', 1) + + track_data = { + 'id': meta_id, + 'name': metadata.get('name', ''), + 'artists': [{'name': metadata.get('artist', '')}], + 'album': album_data, + 'duration_ms': metadata.get('duration_ms', 0), + 'track_number': track_number, + 'disc_number': disc_number, + '_is_explicit_album_download': bool(full_album_data or (album_data.get('id'))), + } + + # Build explicit context if we have full album data + if full_album_data or album_data.get('id'): + track_data['_explicit_album_context'] = full_album_data if isinstance(full_album_data, dict) else album_data + track_data['_explicit_artist_context'] = {'name': metadata.get('artist', ''), 'id': '', 'genres': []} + + # Create batch + with tasks_lock: + download_batches[batch_id] = { + 'queue': [task_id], + 'queue_index': 1, # Already past the first (only) item + 'active_count': 1, # One worker is about to start + 'max_concurrent': 1, + 'playlist_id': f'redownload_{track_id}', + 'playlist_name': f"Redownload: {metadata.get('artist', '')} - {metadata.get('name', '')}", + 'phase': 'downloading', + 'total_tracks': 1, + 'completed_count': 0, + 'failed_count': 0, + 'cancelled_tracks': set(), + 'permanently_failed_tracks': [], + 'force_download': True, + 'auto_initiated': False, + } + + download_tasks[task_id] = { + 'status': 'queued', + 'track_info': track_data, + 'playlist_id': f'redownload_{track_id}', + 'batch_id': batch_id, + 'track_index': 0, + 'download_id': None, + 'username': None, + 'filename': None, + 'retry_count': 0, + 'cached_candidates': [], + 'used_sources': set(), + 'status_change_time': time.time(), + 'metadata_enhanced': False, + 'error_message': None, + '_redownload_context': { + 'library_track_id': track_id, + 'old_file_path': old_file_path, + 'delete_old_file': delete_old, + }, + } + + # Build a TrackResult-like candidate and submit to download + def _run_redownload(): + try: + from core.soulseek_client import TrackResult + from core.itunes_client import Track as MetaTrack + tr = TrackResult( + username=candidate['username'], + filename=candidate['filename'], + size=candidate.get('size', 0), + bitrate=candidate.get('bitrate', 0), + duration=candidate.get('duration', 0), + quality=candidate.get('quality', ''), + free_upload_slots=candidate.get('free_upload_slots', 0), + upload_speed=candidate.get('upload_speed', 0), + queue_length=candidate.get('queue_length', 0), + ) + tr.artist = metadata.get('artist', '') + tr.title = metadata.get('name', '') + tr.album = metadata.get('album', '') + tr.confidence = candidate.get('confidence', 1.0) + + # Build a proper Track object (not a dict) — _attempt_download_with_candidates + # accesses track.artists, track.album etc. as attributes + artist_name = metadata.get('artist', '') + track_obj = MetaTrack( + id=metadata.get('id', ''), + name=metadata.get('name', ''), + artists=[artist_name] if artist_name else ['Unknown'], + album=metadata.get('album', ''), + duration_ms=metadata.get('duration_ms', 0), + popularity=0, + ) + + _attempt_download_with_candidates(task_id, [tr], track_obj, batch_id) + except Exception as e: + logger.error(f"Redownload failed: {e}", exc_info=True) + with tasks_lock: + if task_id in download_tasks: + download_tasks[task_id]['status'] = 'failed' + download_tasks[task_id]['error_message'] = str(e) + + missing_download_executor.submit(_run_redownload) + + return jsonify({ + "success": True, + "task_id": task_id, + "batch_id": batch_id, + "message": "Redownload started", + }) + except Exception as e: + logger.error(f"Error starting redownload: {e}", exc_info=True) + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/web_server.py b/web_server.py index 6664dccb..3517dfac 100644 --- a/web_server.py +++ b/web_server.py @@ -11777,197 +11777,15 @@ def redownload_search_sources(track_id): return jsonify({"success": False, "error": str(e)}), 500 -@app.route('/api/library/track//redownload/start', methods=['POST']) -def redownload_start(track_id): - """Start downloading a specific track from a selected source to replace the current file.""" - try: - data = request.get_json() - metadata = data.get('metadata', {}) - candidate = data.get('candidate', {}) - delete_old = data.get('delete_old_file', True) - - if not candidate.get('username') or not candidate.get('filename'): - return jsonify({"success": False, "error": "candidate with username and filename required"}), 400 - - # Get current track info for old file path - database = get_database() - conn = database._get_connection() - cursor = conn.cursor() - cursor.execute("SELECT file_path FROM tracks WHERE id = ?", (track_id,)) - row = cursor.fetchone() - conn.close() - - old_file_path = None - if row and row['file_path'] and delete_old: - old_file_path = _resolve_library_file_path(row['file_path']) - - task_id = f"redownload_{track_id}_{int(time.time())}" - batch_id = f"redownload_batch_{track_id}" - - # Fetch full track details from the metadata source for pipeline parity - # This gives us track_number, disc_number, full album data - meta_source = metadata.get('_source', '') - meta_id = metadata.get('id', '') - full_track_details = None - full_album_data = None - - if meta_id: - try: - if meta_source == 'spotify' and spotify_client and spotify_client.is_authenticated(): - full_track_details = spotify_client.get_track_details(meta_id) - if full_track_details and full_track_details.get('album', {}).get('id'): - full_album_data = spotify_client.get_album(full_track_details['album']['id']) - elif meta_source == 'itunes': - _it = _get_itunes_client() - results = _it._lookup(id=meta_id, entity='song') - if results: - for r in results: - if r.get('wrapperType') == 'track': - full_track_details = r - break - elif meta_source == 'deezer': - _dz = _get_deezer_client() - full_track_details = _dz._api_get(f'track/{meta_id}') - except Exception as e: - logger.debug(f"[Redownload] Could not fetch full track details: {e}") - - # Build track data with full metadata for pipeline parity - track_number = None - disc_number = 1 - album_data = {'name': metadata.get('album', '')} - - if full_track_details: - if meta_source == 'spotify': - track_number = full_track_details.get('track_number') - disc_number = full_track_details.get('disc_number', 1) - album_raw = full_track_details.get('album', {}) - if album_raw: - album_images = album_raw.get('images', []) - album_data = { - 'id': album_raw.get('id', ''), - 'name': album_raw.get('name', metadata.get('album', '')), - 'release_date': album_raw.get('release_date', ''), - 'album_type': album_raw.get('album_type', 'album'), - 'total_tracks': album_raw.get('total_tracks', 0), - 'images': album_images, - 'image_url': album_images[0]['url'] if album_images else '', - } - elif meta_source == 'itunes': - track_number = full_track_details.get('trackNumber') - disc_number = full_track_details.get('discNumber', 1) - elif meta_source == 'deezer': - track_number = full_track_details.get('track_position') - disc_number = full_track_details.get('disk_number', 1) - - track_data = { - 'id': meta_id, - 'name': metadata.get('name', ''), - 'artists': [{'name': metadata.get('artist', '')}], - 'album': album_data, - 'duration_ms': metadata.get('duration_ms', 0), - 'track_number': track_number, - 'disc_number': disc_number, - '_is_explicit_album_download': bool(full_album_data or (album_data.get('id'))), - } - - # Build explicit context if we have full album data - if full_album_data or album_data.get('id'): - track_data['_explicit_album_context'] = full_album_data if isinstance(full_album_data, dict) else album_data - track_data['_explicit_artist_context'] = {'name': metadata.get('artist', ''), 'id': '', 'genres': []} - - # Create batch - with tasks_lock: - download_batches[batch_id] = { - 'queue': [task_id], - 'queue_index': 1, # Already past the first (only) item - 'active_count': 1, # One worker is about to start - 'max_concurrent': 1, - 'playlist_id': f'redownload_{track_id}', - 'playlist_name': f"Redownload: {metadata.get('artist', '')} - {metadata.get('name', '')}", - 'phase': 'downloading', - 'total_tracks': 1, - 'completed_count': 0, - 'failed_count': 0, - 'cancelled_tracks': set(), - 'permanently_failed_tracks': [], - 'force_download': True, - 'auto_initiated': False, - } - - download_tasks[task_id] = { - 'status': 'queued', - 'track_info': track_data, - 'playlist_id': f'redownload_{track_id}', - 'batch_id': batch_id, - 'track_index': 0, - 'download_id': None, - 'username': None, - 'filename': None, - 'retry_count': 0, - 'cached_candidates': [], - 'used_sources': set(), - 'status_change_time': time.time(), - 'metadata_enhanced': False, - 'error_message': None, - '_redownload_context': { - 'library_track_id': track_id, - 'old_file_path': old_file_path, - 'delete_old_file': delete_old, - }, - } - - # Build a TrackResult-like candidate and submit to download - def _run_redownload(): - try: - from core.soulseek_client import TrackResult - from core.itunes_client import Track as MetaTrack - tr = TrackResult( - username=candidate['username'], - filename=candidate['filename'], - size=candidate.get('size', 0), - bitrate=candidate.get('bitrate', 0), - duration=candidate.get('duration', 0), - quality=candidate.get('quality', ''), - free_upload_slots=candidate.get('free_upload_slots', 0), - upload_speed=candidate.get('upload_speed', 0), - queue_length=candidate.get('queue_length', 0), - ) - tr.artist = metadata.get('artist', '') - tr.title = metadata.get('name', '') - tr.album = metadata.get('album', '') - tr.confidence = candidate.get('confidence', 1.0) - - # Build a proper Track object (not a dict) — _attempt_download_with_candidates - # accesses track.artists, track.album etc. as attributes - artist_name = metadata.get('artist', '') - track_obj = MetaTrack( - id=metadata.get('id', ''), - name=metadata.get('name', ''), - artists=[artist_name] if artist_name else ['Unknown'], - album=metadata.get('album', ''), - duration_ms=metadata.get('duration_ms', 0), - popularity=0, - ) - - _attempt_download_with_candidates(task_id, [tr], track_obj, batch_id) - except Exception as e: - logger.error(f"Redownload failed: {e}", exc_info=True) - with tasks_lock: - if task_id in download_tasks: - download_tasks[task_id]['status'] = 'failed' - download_tasks[task_id]['error_message'] = str(e) +from core.library.redownload import ( + redownload_start as _redownload_start_impl, + init as _init_redownload, +) - missing_download_executor.submit(_run_redownload) - return jsonify({ - "success": True, - "task_id": task_id, - "batch_id": batch_id, - "message": "Redownload started", - }) - except Exception as e: - logger.error(f"Error starting redownload: {e}", exc_info=True) - return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/library/track//redownload/start', methods=['POST']) +def redownload_start(track_id): + return _redownload_start_impl(track_id) @app.route('/api/library/artist//sync', methods=['POST']) @@ -33155,6 +32973,12 @@ _init_wishlist_failed( sweep_fn=_sweep_empty_download_directories, ) +_init_redownload( + resolve_library_file_path_fn=_resolve_library_file_path, + attempt_download_with_candidates_fn=_attempt_download_with_candidates, + executor=missing_download_executor, +) + _init_debug_info( soulsync_version=SOULSYNC_VERSION, direct_run=_DIRECT_RUN,