import os import re import asyncio from typing import List, Optional import httpx from yandex_music import Client from app.config import settings from app.providers.base import BaseProvider, TrackMetadata class YandexMusicProvider(BaseProvider): def __init__(self, token: Optional[str] = None): self.token = token or settings.YANDEX_MUSIC_TOKEN self._client: Optional[Client] = None def get_client(self) -> Client: if not self._client: if not self.token: raise ValueError("Yandex Music Token is not configured.") self._client = Client(self.token).init() return self._client def can_handle(self, url: str) -> bool: return "music.yandex." in url or url == "yandex:likes" async def resolve(self, url: str) -> List[TrackMetadata]: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._resolve_sync, url) def _resolve_sync(self, url: str) -> List[TrackMetadata]: client = self.get_client() tracks: List[TrackMetadata] = [] if url == "yandex:likes": likes = client.users_likes_tracks() if not likes or not likes.tracks: return [] track_ids = [t.id for t in likes.tracks] # Fetch in batches of 100 for i in range(0, len(track_ids), 100): batch = track_ids[i:i + 100] full_tracks = client.tracks(batch) for t in full_tracks: meta = self._convert_track(t) if meta: tracks.append(meta) return tracks # Parse URL # e.g., https://music.yandex.ru/album/123/track/456 track_match = re.search(r'/album/(\d+)/track/(\d+)', url) if track_match: track_id = track_match.group(2) t = client.tracks([track_id])[0] meta = self._convert_track(t) return [meta] if meta else [] # Direct track match: /track/123 direct_track = re.search(r'/track/(\d+)', url) if direct_track: track_id = direct_track.group(1) t = client.tracks([track_id])[0] meta = self._convert_track(t) return [meta] if meta else [] # Album match: /album/123 album_match = re.search(r'/album/(\d+)', url) if album_match: album_id = album_match.group(1) album = client.albums_with_tracks(album_id) if album and album.volumes: for vol in album.volumes: for t in vol: meta = self._convert_track(t) if meta: tracks.append(meta) return tracks # Playlist match: /users/username/playlists/123 playlist_match = re.search(r'/users/([^/]+)/playlists/(\d+)', url) if playlist_match: owner = playlist_match.group(1) kind = playlist_match.group(2) pl = client.users_playlists(kind, owner) if pl and pl.tracks: for item in pl.tracks: meta = self._convert_track(item.track) if meta: tracks.append(meta) return tracks return [] def _convert_track(self, t) -> Optional[TrackMetadata]: if not t: return None artists = ", ".join(a.name for a in t.artists) if t.artists else "Unknown" album_name = t.albums[0].title if t.albums else "Singles" year = t.albums[0].year if t.albums and t.albums[0].year else None genre = t.albums[0].genre if t.albums and t.albums[0].genre else None track_num = t.albums[0].track_position.index if t.albums and t.albums[0].track_position else None cover_url = None if t.cover_uri: cover_url = "https://" + t.cover_uri.replace("%%", "1000x1000") return TrackMetadata( service="yandex", service_id=str(t.id), title=t.title, artist=artists, album=album_name, track_number=track_num, year=year, genre=genre, duration_ms=t.duration_ms, is_explicit=bool(t.content_warning == "explicit"), cover_url=cover_url, source_url=f"https://music.yandex.ru/track/{t.id}" ) async def download_track(self, track: TrackMetadata, dest_path: str) -> bool: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, self._download_sync, track, dest_path) def _download_sync(self, track: TrackMetadata, dest_path: str) -> bool: client = self.get_client() try: full_track = client.tracks([track.service_id])[0] if not full_track.available: print(f"Track {track.title} is not available in Yandex catalog.") return False tmp_download_path = dest_path + ".tmp" full_track.download(tmp_download_path, bitrate_in_kbps=320) # Fetch cover image cover_bytes = None if track.cover_url: try: with httpx.Client(timeout=10.0) as http_client: resp = http_client.get(track.cover_url) if resp.status_code == 200: cover_bytes = resp.content except Exception as e: print(f"Warning: Failed to fetch cover for {track.title}: {e}") # Apply ID3 tags from app.core.tagger import apply_id3_tags apply_id3_tags( tmp_download_path, title=track.title, artist=track.artist, album=track.album, track_number=track.track_number, year=track.year, genre=track.genre, cover_bytes=cover_bytes ) os.replace(tmp_download_path, dest_path) return True except Exception as e: print(f"Error downloading Yandex track {track.title}: {e}") return False