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,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
|
||||
Reference in new issue
Block a user