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

+131
View File
@@ -0,0 +1,131 @@
import asyncio
import uuid
from typing import Dict, List, Optional
from datetime import datetime
from pydantic import BaseModel
from app.config import settings
from app.db import is_track_downloaded, record_downloaded_track
from app.core.tagger import get_track_dest_path
from app.core.lyrics import fetch_and_save_lyrics
from app.core.navidrome import trigger_navidrome_scan
from app.providers.base import BaseProvider, TrackMetadata
from app.providers.yandex import YandexMusicProvider
from app.providers.universal import UniversalProvider
class TaskInfo(BaseModel):
id: str
target: str
status: str # pending, resolving, downloading, completed, failed
progress: int = 0
total: int = 0
current_item: str = ""
error: Optional[str] = None
created_at: str
updated_at: str
class SyncManager:
def __init__(self):
self.tasks: Dict[str, TaskInfo] = {}
self.yandex_provider = YandexMusicProvider()
self.universal_provider = UniversalProvider()
self._lock = asyncio.Lock()
def get_provider(self, url: str) -> BaseProvider:
if self.yandex_provider.can_handle(url):
return self.yandex_provider
return self.universal_provider
async def start_sync_task(self, target: str) -> TaskInfo:
task_id = str(uuid.uuid4())[:8]
now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
info = TaskInfo(
id=task_id,
target=target,
status="pending",
created_at=now,
updated_at=now
)
self.tasks[task_id] = info
asyncio.create_task(self._process_task(task_id, target))
return info
async def _process_task(self, task_id: str, target: str):
task = self.tasks[task_id]
provider = self.get_provider(target)
try:
task.status = "resolving"
task.current_item = "Fetching metadata..."
task.updated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
tracks: List[TrackMetadata] = await provider.resolve(target)
task.total = len(tracks)
if not tracks:
task.status = "completed"
task.current_item = "No tracks found or URL empty."
return
task.status = "downloading"
downloaded_any = False
for idx, track in enumerate(tracks, start=1):
task.progress = idx
task.current_item = f"{track.artist} - {track.title}"
task.updated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# Check if already downloaded
if is_track_downloaded(track.service, track.service_id):
continue
# Destination path
dest_path = get_track_dest_path(
settings.MUSIC_DIR,
artist=track.artist,
album=track.album or "Singles",
track_num=track.track_number,
title=track.title,
ext="mp3"
)
# Attempt download
success = await provider.download_track(track, dest_path)
# If Yandex failed (e.g. censored or region blocked), fallback to universal search
if not success and track.service == "yandex":
print(f"Fallback to universal search for: {track.artist} - {track.title}")
success = await self.universal_provider.download_track(track, dest_path)
if success:
downloaded_any = True
# Fetch synchronized lyrics if enabled
if settings.DOWNLOAD_LYRICS:
query = f"{track.artist} {track.title}"
fetch_and_save_lyrics(dest_path, query)
# Save to DB
record_downloaded_track(
service=track.service,
service_id=track.service_id,
artist=track.artist,
title=track.title,
album=track.album or "Singles",
file_path=dest_path
)
task.status = "completed"
task.current_item = f"Finished processing {task.total} tracks."
task.updated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
# Trigger Navidrome scan
if downloaded_any:
await trigger_navidrome_scan()
except Exception as e:
task.status = "failed"
task.error = str(e)
task.current_item = f"Error: {e}"
task.updated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
sync_manager = SyncManager()