feat: initial implementation of navidrome-music-sync with Yandex, Spotify, Apple, YouTube support and Web UI
This commit is contained in:
1 parent
44c67ab49d
commit
5b6c2c72f7
23 files changed
+1445
-2
No files matched your search
@@ -0,0 +1,156 @@
|
||||
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 []
|
||||
|
||||
# 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
|
||||
Reference in new issue
Block a user