Fix deezer multi-album tracks

This commit is contained in:
Xoconoch
2025-08-09 09:04:33 -06:00
parent c3ea528f17
commit 5c3364a4f3
4 changed files with 193 additions and 81 deletions

View File

@@ -46,6 +46,7 @@ from deezspot.libutils.others_settings import (
)
from deezspot.libutils.logging_utils import ProgressReporter, logger, report_progress
import requests
from difflib import SequenceMatcher
from deezspot.models.callback.callbacks import (
trackCallbackObject,
@@ -395,12 +396,103 @@ class DeeLogin:
logger.warning(msg)
raise TrackNotFound(url=link_track, message=msg)
isrc_code = external_ids['isrc']
def _sim(a: str, b: str) -> float:
a = (a or '').strip().lower()
b = (b or '').strip().lower()
if not a or not b:
return 0.0
return SequenceMatcher(None, a, b).ratio()
spo_title = track_json.get('name', '')
spo_album_title = (track_json.get('album') or {}).get('name', '')
spo_tracknum = int(track_json.get('track_number') or 0)
spo_isrc = (external_ids.get('isrc') or '').upper()
# 1) Primary attempt: /track/isrc:CODE then validate with strict checks
try:
return self.convert_isrc_to_dee_link_track(isrc_code)
except TrackNotFound as e:
logger.error(f"Failed to convert Spotify track {link_track} (ISRC: {isrc_code}) to Deezer link: {e.message}")
raise TrackNotFound(url=link_track, message=f"Failed to find Deezer equivalent for ISRC {isrc_code} from Spotify track {link_track}: {e.message}") from e
dz = API.get_track_json(f"isrc:{spo_isrc}")
except Exception:
dz = {}
def _track_ok(dz_json: dict) -> bool:
if not dz_json or not dz_json.get('id'):
return False
title_match = max(
_sim(spo_title, dz_json.get('title', '')),
_sim(spo_title, dz_json.get('title_short', '')),
)
album_match = _sim(spo_album_title, (dz_json.get('album') or {}).get('title', ''))
tn = int(dz_json.get('track_position') or dz_json.get('track_number') or 0)
return title_match >= 0.90 and album_match >= 0.90 and tn == spo_tracknum
if _track_ok(dz):
deezer_id = dz['id']
return f"https://www.deezer.com/track/{deezer_id}"
# 2) Fallback: search tracks by "title album" and validate, minimizing extra calls
query = f'"{spo_title} {spo_album_title}"'
try:
candidates = API.search_tracks_raw(query, limit=5)
except Exception:
candidates = []
for cand in candidates:
title_match = max(
_sim(spo_title, cand.get('title', '')),
_sim(spo_title, cand.get('title_short', '')),
)
album_match = _sim(spo_album_title, (cand.get('album') or {}).get('title', ''))
if title_match < 0.90 or album_match < 0.90:
continue
c_id = cand.get('id')
if not c_id:
continue
# Fetch details only for promising candidates to check track number and ISRC
try:
dzc = API.get_track_json(str(c_id))
except Exception:
continue
tn = int(dzc.get('track_position') or dzc.get('track_number') or 0)
if tn != spo_tracknum:
continue
if (dzc.get('isrc') or '').upper() != spo_isrc:
continue
return f"https://www.deezer.com/track/{dzc['id']}"
# 3) Last resort: search albums by album title; inspect tracks to find exact track number
try:
album_candidates = API.search_albums_raw(f'"{spo_album_title}"', limit=5)
except Exception:
album_candidates = []
for alb in album_candidates:
if _sim(spo_album_title, alb.get('title', '')) < 0.90:
continue
alb_id = alb.get('id')
if not alb_id:
continue
try:
# Use standardized album object to get detailed tracks (includes ISRC in IDs)
full_album = API.get_album(alb_id)
except Exception:
continue
# full_album.tracks is a list of trackAlbumObject with ids.isrc and track_number
for t in getattr(full_album, 'tracks', []) or []:
try:
t_title = getattr(t, 'title', '')
t_num = int(getattr(t, 'track_number', 0))
t_isrc = (getattr(getattr(t, 'ids', None), 'isrc', '') or '').upper()
except Exception:
continue
if t_num != spo_tracknum:
continue
if _sim(spo_title, t_title) < 0.90:
continue
if t_isrc != spo_isrc:
continue
return f"https://www.deezer.com/track/{getattr(getattr(t, 'ids', None), 'deezer', '')}"
raise TrackNotFound(url=link_track, message=f"Failed to find Deezer equivalent for ISRC {spo_isrc} from Spotify track {link_track}")
def convert_isrc_to_dee_link_track(self, isrc_code: str) -> str:
if not isinstance(isrc_code, str) or not isrc_code:
@@ -432,71 +524,55 @@ class DeeLogin:
spotify_album_data = Spo.get_album(ids)
# Method 1: Try UPC
try:
external_ids = spotify_album_data.get('external_ids')
if external_ids and 'upc' in external_ids:
upc_base = str(external_ids['upc']).lstrip('0')
if upc_base:
logger.debug(f"Attempting Deezer album search with UPC: {upc_base}")
try:
deezer_album_obj = API.get_album(f"upc:{upc_base}")
if deezer_album_obj and deezer_album_obj.type and deezer_album_obj.ids and deezer_album_obj.ids.deezer:
link_dee = f"https://www.deezer.com/{deezer_album_obj.type}/{deezer_album_obj.ids.deezer}"
logger.info(f"Found Deezer album via UPC: {link_dee}")
except NoDataApi:
logger.debug(f"No Deezer album found for UPC: {upc_base}")
except Exception as e_upc_search:
logger.warning(f"Error during Deezer API call for UPC {upc_base}: {e_upc_search}")
else:
logger.debug("No UPC found in Spotify data for album link conversion.")
except Exception as e_upc_block:
logger.error(f"Error processing UPC for album {link_album}: {e_upc_block}")
def _sim(a: str, b: str) -> float:
a = (a or '').strip().lower()
b = (b or '').strip().lower()
if not a or not b:
return 0.0
return SequenceMatcher(None, a, b).ratio()
# Method 2: Try ISRC if UPC failed
if not link_dee:
logger.debug(f"UPC method failed or skipped for {link_album}. Attempting ISRC method.")
spo_album_title = spotify_album_data.get('name', '')
# Prefer main artist name for better search, if available
spo_artists = spotify_album_data.get('artists') or []
spo_main_artist = (spo_artists[0].get('name') if spo_artists else '') or ''
external_ids = spotify_album_data.get('external_ids') or {}
spo_upc = str(external_ids.get('upc') or '').strip()
# 1) Primary attempt: /album/upc:CODE then validate title similarity
dz_album = {}
if spo_upc:
try:
spotify_total_tracks = spotify_album_data.get('total_tracks')
spotify_tracks_items = spotify_album_data.get('tracks', {}).get('items', [])
dz_album = API.get_album_json(f"upc:{spo_upc}")
except Exception:
dz_album = {}
if dz_album.get('id') and _sim(spo_album_title, dz_album.get('title', '')) >= 0.90:
link_dee = f"https://www.deezer.com/album/{dz_album['id']}"
return link_dee
if not spotify_tracks_items:
logger.warning(f"No track items in Spotify data for {link_album} to attempt ISRC lookup.")
else:
for track_item in spotify_tracks_items:
try:
track_spotify_link = track_item.get('external_urls', {}).get('spotify')
if not track_spotify_link: continue
# 2) Fallback: search albums by album title (+ main artist) and confirm UPC
q = f'"{spo_album_title}" {spo_main_artist}'.strip()
try:
candidates = API.search_albums_raw(q, limit=5)
except Exception:
candidates = []
spotify_track_info = Spo.get_track(track_spotify_link)
isrc_value = spotify_track_info.get('external_ids', {}).get('isrc')
if not isrc_value: continue
for cand in candidates:
if _sim(spo_album_title, cand.get('title', '')) < 0.90:
continue
c_id = cand.get('id')
if not c_id:
continue
try:
dzc = API.get_album_json(str(c_id))
except Exception:
continue
upc = str(dzc.get('upc') or '').strip()
if spo_upc and upc and spo_upc != upc:
continue
link_dee = f"https://www.deezer.com/album/{c_id}"
return link_dee
logger.debug(f"Attempting Deezer track search with ISRC: {isrc_value}")
deezer_track_obj = API.get_track(f"isrc:{isrc_value}")
if deezer_track_obj and deezer_track_obj.album and deezer_track_obj.album.ids.deezer:
deezer_album_id = deezer_track_obj.album.ids.deezer
full_deezer_album_obj = API.get_album(deezer_album_id)
if (full_deezer_album_obj and
full_deezer_album_obj.total_tracks == spotify_total_tracks and
full_deezer_album_obj.type and full_deezer_album_obj.ids and full_deezer_album_obj.ids.deezer):
link_dee = f"https://www.deezer.com/{full_deezer_album_obj.type}/{full_deezer_album_obj.ids.deezer}"
logger.info(f"Found Deezer album via ISRC ({isrc_value}): {link_dee}")
break
except NoDataApi:
logger.debug(f"No Deezer track/album found for ISRC: {isrc_value}")
except Exception as e_isrc_track_search:
logger.warning(f"Error during Deezer search for ISRC {isrc_value}: {e_isrc_track_search}")
if not link_dee:
logger.warning(f"ISRC method completed for {link_album}, but no matching Deezer album found.")
except Exception as e_isrc_block:
logger.error(f"Error during ISRC processing block for {link_album}: {e_isrc_block}")
if not link_dee:
raise AlbumNotFound(f"Failed to convert Spotify album link {link_album} to a Deezer link after all attempts.")
return link_dee
raise AlbumNotFound(f"Failed to convert Spotify album link {link_album} to a Deezer link after all attempts.")
def download_trackspo(
self, link_track,
@@ -622,6 +698,7 @@ class DeeLogin:
from deezspot.models.callback.playlist import (
artistTrackPlaylistObject,
albumTrackPlaylistObject,
artistAlbumTrackPlaylistObject,
trackPlaylistObject
)
@@ -643,7 +720,6 @@ class DeeLogin:
# Process album artists
album_artists = []
if album_info.get('artists'):
from deezspot.models.callback.playlist import artistAlbumTrackPlaylistObject
album_artists = [
artistAlbumTrackPlaylistObject(
name=artist.get('name'),

View File

@@ -63,6 +63,38 @@ class API:
return tracking(infos)
@classmethod
def get_track_json(cls, track_id_or_isrc: str) -> dict:
"""Return raw Deezer track JSON. Accepts numeric id or 'isrc:CODE'."""
url = f"{cls.__api_link}track/{track_id_or_isrc}"
return cls.__get_api(url)
@classmethod
def search_tracks_raw(cls, query: str, limit: int = 25) -> list[dict]:
"""Return raw track objects from search for more complete fields (readable, rank, etc.)."""
url = f"{cls.__api_link}search/track"
params = {"q": query, "limit": limit}
infos = cls.__get_api(url, params=params)
if infos.get('total', 0) == 0:
raise NoDataApi(query)
return infos.get('data', [])
@classmethod
def search_albums_raw(cls, query: str, limit: int = 25) -> list[dict]:
"""Return raw album objects from search to allow title similarity checks."""
url = f"{cls.__api_link}search/album"
params = {"q": query, "limit": limit}
infos = cls.__get_api(url, params=params)
if infos.get('total', 0) == 0:
raise NoDataApi(query)
return infos.get('data', [])
@classmethod
def get_album_json(cls, album_id_or_upc: str) -> dict:
"""Return raw album JSON. Accepts numeric id or 'upc:CODE'."""
url = f"{cls.__api_link}album/{album_id_or_upc}"
return cls.__get_api(url)
@classmethod
def get_album(cls, album_id):
url = f"{cls.__api_link}album/{album_id}"

View File

@@ -14,15 +14,15 @@ authors = [
]
dependencies = [
"mutagen",
"pycryptodome",
"requests",
"spotipy",
"tqdm",
"fastapi",
"uvicorn[standard]",
"spotipy-anon",
"librespot-spotizerr"
"mutagen==1.47.0",
"pycryptodome==3.23.0",
"requests==2.32.3",
"spotipy==2.25.1",
"tqdm==4.67.1",
"fastapi==0.116.1",
"uvicorn[standard]==0.35.0",
"spotipy-anon==1.5.2",
"librespot-spotizerr==0.2.10"
]
[project.urls]

View File

@@ -19,10 +19,14 @@ setup(
packages = find_packages(include=["deezspot", "deezspot.*"]),
install_requires = [
"mutagen", "pycryptodome", "requests",
"spotipy", "tqdm", "fastapi",
"uvicorn[standard]",
"spotipy-anon",
"librespot-spotizerr"
"mutagen==1.47.0",
"pycryptodome==3.23.0",
"requests==2.32.3",
"spotipy==2.25.1",
"tqdm==4.67.1",
"fastapi==0.116.1",
"uvicorn[standard]==0.35.0",
"spotipy-anon==1.5.2",
"librespot-spotizerr==0.2.10"
],
)