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 @@
|
||||
"""Core modules for tagging, lyrics, and navidrome"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
Reference in new issue
Block a user