You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/library/redownload.py

260 lines
11 KiB

"""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.download_plugins.types 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