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 @@
"""Core modules for tagging, lyrics, and navidrome"""
+18
View File
@@ -0,0 +1,18 @@
import os
import syncedlyrics
def fetch_and_save_lyrics(dest_audio_path: str, search_query: str) -> bool:
"""Download synchronized LRC lyrics and save beside audio file."""
lrc_path = os.path.splitext(dest_audio_path)[0] + ".lrc"
if os.path.exists(lrc_path):
return True
try:
lrc = syncedlyrics.search(search_query)
if lrc:
with open(lrc_path, "w", encoding="utf-8") as f:
f.write(lrc)
return True
except Exception as e:
print(f"Warning: Failed to fetch lyrics for '{search_query}': {e}")
return False
+30
View File
@@ -0,0 +1,30 @@
import httpx
from app.config import settings
async def trigger_navidrome_scan() -> bool:
"""Notify Navidrome server to start scanning library for new files."""
if not settings.NAVIDROME_URL or not settings.NAVIDROME_USER:
return False
base_url = settings.NAVIDROME_URL.rstrip("/")
scan_url = f"{base_url}/rest/startScan.view"
params = {
"u": settings.NAVIDROME_USER,
"p": settings.NAVIDROME_PASSWORD,
"v": "1.16.1",
"c": "navidrome-music-sync",
"f": "json"
}
try:
async with httpx.AsyncClient(verify=False, timeout=10.0) as client:
resp = await client.get(scan_url, params=params)
if resp.status_code == 200:
print("Navidrome library scan initiated successfully.")
return True
else:
print(f"Navidrome scan request returned status {resp.status_code}")
except Exception as e:
print(f"Failed to trigger Navidrome scan: {e}")
return False
+71
View File
@@ -0,0 +1,71 @@
import os
import re
from typing import Optional
from mutagen.id3 import ID3, TIT2, TPE1, TALB, TRCK, TYER, APIC, TCON, ID3NoHeaderError
from mutagen.mp3 import MP3
def sanitize_filename(name: str) -> str:
"""Sanitize directory or file name for filesystem safety."""
if not name:
return "Unknown"
# Replace invalid chars with safe equivalents
s = re.sub(r'[\\/*?:"<>|]', "", name)
s = s.strip(". ")
return s if s else "Unknown"
def get_track_dest_path(base_dir: str, artist: str, album: str, track_num: Optional[int], title: str, ext: str = "mp3") -> str:
"""Generate structured path: base_dir/Artist/Album/01 - Title.ext"""
safe_artist = sanitize_filename(artist)
safe_album = sanitize_filename(album) if album else "Singles"
safe_title = sanitize_filename(title)
track_prefix = f"{track_num:02d} - " if track_num and track_num > 0 else ""
filename = f"{track_prefix}{safe_title}.{ext}"
target_dir = os.path.join(base_dir, safe_artist, safe_album)
os.makedirs(target_dir, exist_ok=True)
return os.path.join(target_dir, filename)
def apply_id3_tags(
file_path: str,
title: str,
artist: str,
album: Optional[str] = None,
track_number: Optional[int] = None,
year: Optional[int] = None,
genre: Optional[str] = None,
cover_bytes: Optional[bytes] = None,
cover_mime: str = "image/jpeg"
):
"""Write standard ID3v2 tags and embed album artwork into MP3 file."""
try:
try:
audio = ID3(file_path)
except ID3NoHeaderError:
audio = ID3()
if title:
audio["TIT2"] = TIT2(encoding=3, text=title)
if artist:
audio["TPE1"] = TPE1(encoding=3, text=artist)
if album:
audio["TALB"] = TALB(encoding=3, text=album)
if track_number is not None:
audio["TRCK"] = TRCK(encoding=3, text=str(track_number))
if year is not None:
audio["TYER"] = TYER(encoding=3, text=str(year))
if genre:
audio["TCON"] = TCON(encoding=3, text=genre)
if cover_bytes:
audio["APIC"] = APIC(
encoding=3,
mime=cover_mime,
type=3, # Cover (front)
desc="Cover",
data=cover_bytes
)
audio.save(file_path, v2_version=3)
except Exception as e:
print(f"Warning: Failed to apply ID3 tags to {file_path}: {e}")