feat: initial implementation of navidrome-music-sync with Yandex, Spotify, Apple, YouTube support and Web UI

This commit is contained in:
Antigravity committed 2026-10-12 11:17:18 +03:00
1 parent 44c67ab49d
commit 5b6c2c72f7
23 files changed
+1445 -2

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Music streaming providers"""
+33
View File
@@ -0,0 +1,33 @@
from abc import ABC, abstractmethod
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
class TrackMetadata(BaseModel):
service: str
service_id: str
title: str
artist: str
album: Optional[str] = None
track_number: Optional[int] = None
year: Optional[int] = None
genre: Optional[str] = None
duration_ms: Optional[int] = None
is_explicit: bool = False
cover_url: Optional[str] = None
source_url: Optional[str] = None
class BaseProvider(ABC):
@abstractmethod
def can_handle(self, url: str) -> bool:
"""Check if provider can handle the given URL."""
pass
@abstractmethod
async def resolve(self, url: str) -> List[TrackMetadata]:
"""Resolve URL into a list of TrackMetadata items."""
pass
@abstractmethod
async def download_track(self, track: TrackMetadata, dest_path: str) -> bool:
"""Download track to dest_path."""
pass
+152
View File
@@ -0,0 +1,152 @@
import os
import asyncio
import subprocess
from typing import List, Optional
import yt_dlp
from app.config import settings
from app.providers.base import BaseProvider, TrackMetadata
from app.core.tagger import apply_id3_tags
class UniversalProvider(BaseProvider):
def can_handle(self, url: str) -> bool:
lower = url.lower()
return any(domain in lower for domain in [
"spotify.com",
"music.apple.com",
"youtube.com",
"youtu.be",
"music.youtube.com"
])
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]:
lower = url.lower()
# Handle Spotify / Apple Music via SpotDL parser / metadata
if "spotify.com" in lower or "music.apple.com" in lower:
return self._resolve_spotdl(url)
# Handle YouTube / YouTube Music via yt-dlp
return self._resolve_ytdlp(url)
def _resolve_spotdl(self, url: str) -> List[TrackMetadata]:
# SpotDL resolves metadata seamlessly
tracks: List[TrackMetadata] = []
try:
from spotdl.types.song import Song
from spotdl.utils.search import parse_query
songs = parse_query([url])
for s in songs:
tracks.append(TrackMetadata(
service="spotify" if "spotify" in url else "apple",
service_id=s.url or s.song_id,
title=s.name,
artist=s.artist,
album=s.album_name,
track_number=s.track_number,
year=int(s.year) if s.year else None,
genre=s.genres[0] if s.genres else None,
duration_ms=int(s.duration * 1000) if s.duration else None,
is_explicit=bool(s.explicit),
cover_url=s.cover_url,
source_url=s.url
))
except Exception as e:
print(f"SpotDL resolve error, falling back to yt-dlp: {e}")
return self._resolve_ytdlp(url)
return tracks
def _resolve_ytdlp(self, url: str) -> List[TrackMetadata]:
tracks: List[TrackMetadata] = []
ydl_opts = {
"extract_flat": True,
"quiet": True,
"no_warnings": True,
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return []
entries = info.get("entries") if "entries" in info else [info]
for entry in entries:
if not entry:
continue
title = entry.get("title", "Unknown")
uploader = entry.get("uploader") or entry.get("artist") or "Unknown"
tracks.append(TrackMetadata(
service="youtube",
service_id=entry.get("id", ""),
title=title,
artist=uploader,
album=entry.get("album"),
duration_ms=int(entry.get("duration", 0) * 1000) if entry.get("duration") else None,
cover_url=entry.get("thumbnail"),
source_url=entry.get("webpage_url") or f"https://www.youtube.com/watch?v={entry.get('id')}"
))
except Exception as e:
print(f"yt-dlp resolve error: {e}")
return tracks
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:
tmp_output = dest_path + ".tmp.mp3"
query = f"{track.artist} - {track.title}"
if track.is_explicit:
query += " explicit"
# Search and download best audio via yt-dlp
ydl_opts = {
"format": "bestaudio/best",
"outtmpl": dest_path + ".tmp.%(ext)s",
"postprocessors": [{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "320",
}],
"quiet": True,
"no_warnings": True,
"default_search": "ytsearch1:",
}
try:
target_input = track.source_url if track.service == "youtube" and track.source_url else f"ytsearch1:{query}"
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([target_input])
if os.path.exists(tmp_output):
# Apply high quality tags from metadata
import httpx
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 download cover: {e}")
apply_id3_tags(
tmp_output,
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_output, dest_path)
return True
except Exception as e:
print(f"Failed to download universal track {track.title}: {e}")
return False
+156
View File
@@ -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