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 @@
"""Navidrome Music Sync Package"""
+27
View File
@@ -0,0 +1,27 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
YANDEX_MUSIC_TOKEN: str = ""
MUSIC_DIR: str = "/music"
DATA_DIR: str = "/app/data"
NAVIDROME_URL: str = "https://music.qpid.pro"
NAVIDROME_USER: str = ""
NAVIDROME_PASSWORD: str = ""
DOWNLOAD_LYRICS: bool = True
AUDIO_FORMAT: str = "mp3"
AUDIO_BITRATE: str = "320k"
HOST: str = "0.0.0.0"
PORT: int = 8005
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "ignore"
settings = Settings()
# Ensure directories exist
os.makedirs(settings.MUSIC_DIR, exist_ok=True)
os.makedirs(settings.DATA_DIR, exist_ok=True)
os.makedirs(os.path.join(settings.DATA_DIR, "tmp"), exist_ok=True)
+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}")
+88
View File
@@ -0,0 +1,88 @@
import sqlite3
import os
from datetime import datetime
from typing import Optional, List, Dict, Any
from app.config import settings
DB_PATH = os.path.join(settings.DATA_DIR, "sync.db")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
cursor = conn.cursor()
# Tracks table
cursor.execute("""
CREATE TABLE IF NOT EXISTS downloaded_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT NOT NULL,
service_id TEXT NOT NULL,
artist TEXT NOT NULL,
title TEXT NOT NULL,
album TEXT,
file_path TEXT NOT NULL,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(service, service_id)
)
""")
# Task table
cursor.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
target TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER DEFAULT 0,
total INTEGER DEFAULT 0,
message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
conn.close()
def is_track_downloaded(service: str, service_id: str) -> bool:
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM downloaded_tracks WHERE service = ? AND service_id = ?", (service, str(service_id)))
row = cursor.fetchone()
conn.close()
return row is not None
def record_downloaded_track(service: str, service_id: str, artist: str, title: str, album: str, file_path: str):
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO downloaded_tracks (service, service_id, artist, title, album, file_path, downloaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (service, str(service_id), artist, title, album, file_path, datetime.utcnow()))
conn.commit()
conn.close()
def get_recent_tracks(limit: int = 50) -> List[Dict[str, Any]]:
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
SELECT id, service, service_id, artist, title, album, file_path, downloaded_at
FROM downloaded_tracks
ORDER BY downloaded_at DESC
LIMIT ?
""", (limit,))
rows = [dict(row) for row in cursor.fetchall()]
conn.close()
return rows
def get_tracks_count() -> int:
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM downloaded_tracks")
count = cursor.fetchone()[0]
conn.close()
return count
+65
View File
@@ -0,0 +1,65 @@
import os
from fastapi import FastAPI, Request, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from typing import List, Dict, Any
from app.config import settings
from app.db import init_db, get_recent_tracks, get_tracks_count
from app.queue import sync_manager
app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
# Mount static and templates
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
class DownloadRequest(BaseModel):
url: str
@app.on_event("startup")
def on_startup():
init_db()
@app.get("/")
def read_root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.post("/api/download")
async def start_download(req: DownloadRequest):
url = req.url.strip()
if not url:
raise HTTPException(status_code=400, detail="URL is required")
task = await sync_manager.start_sync_task(url)
return {"task_id": task.id, "status": task.status}
@app.post("/api/sync/yandex-likes")
async def sync_yandex_likes():
if not settings.YANDEX_MUSIC_TOKEN:
raise HTTPException(status_code=400, detail="Yandex Music Token is not configured.")
task = await sync_manager.start_sync_task("yandex:likes")
return {"task_id": task.id, "status": task.status}
@app.get("/api/tasks")
def list_tasks():
return list(sync_manager.tasks.values())
@app.get("/api/tasks/{task_id}")
def get_task(task_id: str):
if task_id not in sync_manager.tasks:
raise HTTPException(status_code=404, detail="Task not found")
return sync_manager.tasks[task_id]
@app.get("/api/history")
def get_history(limit: int = 50):
tracks = get_recent_tracks(limit)
total = get_tracks_count()
return {"total": total, "tracks": tracks}
@app.get("/api/health")
def healthcheck():
return {"status": "ok", "yandex_configured": bool(settings.YANDEX_MUSIC_TOKEN)}
+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
+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()
+126
View File
@@ -0,0 +1,126 @@
document.addEventListener("DOMContentLoaded", () => {
const downloadForm = document.getElementById("downloadForm");
const urlInput = document.getElementById("urlInput");
const syncLikesBtn = document.getElementById("syncLikesBtn");
const tasksList = document.getElementById("tasksList");
const historyBody = document.getElementById("historyBody");
const tracksCount = document.getElementById("tracksCount");
// Load history and active tasks
fetchHistory();
fetchTasks();
setInterval(fetchTasks, 2000);
setInterval(fetchHistory, 10000);
// Form submit
downloadForm.addEventListener("submit", async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
if (!url) return;
try {
const res = await fetch("/api/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.task_id) {
urlInput.value = "";
fetchTasks();
} else {
alert(data.error || "Ошибка запуска загрузки");
}
} catch (err) {
alert("Ошибка сети: " + err);
}
});
// Sync likes button
syncLikesBtn.addEventListener("click", async () => {
if (!confirm("Запустить синхронизацию плейлиста «Мне нравится» из Яндекс Музыки?")) return;
try {
const res = await fetch("/api/sync/yandex-likes", { method: "POST" });
const data = await res.json();
if (data.task_id) {
fetchTasks();
}
} catch (err) {
alert("Ошибка запуска синхронизации: " + err);
}
});
async function fetchTasks() {
try {
const res = await fetch("/api/tasks");
const tasks = await res.json();
renderTasks(tasks);
} catch (err) {
console.error(err);
}
}
function renderTasks(tasks) {
if (!tasks || tasks.length === 0) {
tasksList.innerHTML = `<p class="empty-state">Нет активных загрузок</p>`;
return;
}
tasksList.innerHTML = tasks.map(t => {
const percent = t.total > 0 ? Math.round((t.progress / t.total) * 100) : 0;
return `
<div class="task-item">
<div class="task-header">
<strong>${escapeHtml(t.target)}</strong>
<span class="badge ${t.status === 'completed' ? 'badge-success' : 'badge-info'}">${t.status}</span>
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${percent}%;"></div>
</div>
<div class="task-current">
${t.progress}/${t.total} (${percent}%) • ${escapeHtml(t.current_item || '')}
</div>
</div>
`;
}).join("");
}
async function fetchHistory() {
try {
const res = await fetch("/api/history");
const data = await res.json();
if (tracksCount) tracksCount.textContent = `Загружено: ${data.total || 0}`;
renderHistory(data.tracks || []);
} catch (err) {
console.error(err);
}
}
function renderHistory(tracks) {
if (!tracks || tracks.length === 0) {
historyBody.innerHTML = `<tr><td colspan="5" class="empty-state">Пока нет загрузок</td></tr>`;
return;
}
historyBody.innerHTML = tracks.map(t => `
<tr>
<td><span class="tag-badge tag-${t.service}">${t.service}</span></td>
<td><strong>${escapeHtml(t.artist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(t.album || 'Singles')}</td>
<td style="color: var(--text-secondary);">${formatDate(t.downloaded_at)}</td>
</tr>
`).join("");
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function formatDate(isoStr) {
if (!isoStr) return "";
const d = new Date(isoStr);
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
});
+266
View File
@@ -0,0 +1,266 @@
:root {
--bg-color: #0f172a;
--card-bg: #1e293b;
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--accent-color: #38bdf8;
--accent-hover: #0284c7;
--border-color: #334155;
--success-color: #10b981;
--warning-color: #f59e0b;
--danger-color: #ef4444;
--border-radius: 12px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Inter', system-ui, sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-primary);
min-height: 100vh;
display: flex;
justify-content: center;
padding: 2rem 1rem;
}
.app-container {
width: 100%;
max-width: 900px;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.logo-group {
display: flex;
align-items: center;
gap: 1rem;
}
.logo-icon {
font-size: 2.2rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.875rem;
}
.status-badges {
display: flex;
gap: 0.5rem;
}
.badge {
padding: 0.35rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-success {
background-color: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.badge-info {
background-color: rgba(56, 189, 248, 0.2);
color: #38bdf8;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
}
.card h2 {
font-size: 1.15rem;
margin-bottom: 0.5rem;
}
.card-desc {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.download-form .input-wrapper {
display: flex;
gap: 0.75rem;
}
input[type="text"] {
flex: 1;
background-color: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: #fff;
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: var(--accent-color);
}
.btn {
padding: 0.75rem 1.25rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background-color: var(--accent-color);
color: #0f172a;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-secondary {
background-color: #334155;
color: #f8fafc;
font-size: 0.85rem;
padding: 0.5rem 1rem;
}
.btn-secondary:hover {
background-color: #475569;
}
.quick-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
flex-wrap: wrap;
}
.tasks-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.task-item {
background-color: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
}
.task-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.task-progress-bar {
height: 8px;
background-color: #334155;
border-radius: 4px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.task-progress-fill {
height: 100%;
background-color: var(--accent-color);
width: 0%;
transition: width 0.3s;
}
.task-current {
font-size: 0.8rem;
color: var(--text-secondary);
}
.table-container {
overflow-x: auto;
}
.tracks-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.85rem;
}
.tracks-table th {
padding: 0.75rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.tracks-table td {
padding: 0.75rem;
border-bottom: 1px solid #243248;
}
.tag-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
text-transform: uppercase;
font-weight: 600;
}
.tag-yandex { background-color: #ef4444; color: #fff; }
.tag-spotify { background-color: #10b981; color: #fff; }
.tag-apple { background-color: #ec4899; color: #fff; }
.tag-youtube { background-color: #e11d48; color: #fff; }
.empty-state {
text-align: center;
color: var(--text-secondary);
padding: 1.5rem;
}
.footer {
text-align: center;
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 1rem;
}
.footer a {
color: var(--accent-color);
text-decoration: none;
}
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Navidrome Music Sync</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="app-container">
<header class="header">
<div class="logo-group">
<span class="logo-icon">🎵</span>
<div>
<h1>Navidrome Music Sync</h1>
<p class="subtitle">Импорт и синхронизация музыки в Navidrome</p>
</div>
</div>
<div class="status-badges">
<span class="badge badge-success" id="yandexStatus">Яндекс Музыка: Активна</span>
<span class="badge badge-info" id="tracksCount">Загружено: 0</span>
</div>
</header>
<main class="main-content">
<!-- Input Card -->
<section class="card input-card">
<h2>📥 Импорт музыки</h2>
<p class="card-desc">Вставьте ссылку на трек, альбом или плейлист из <b>Яндекс Музыки</b>, <b>Spotify</b>, <b>Apple Music</b> или <b>YouTube</b>:</p>
<form id="downloadForm" class="download-form">
<div class="input-wrapper">
<input type="text" id="urlInput" placeholder="https://music.yandex.ru/... или https://open.spotify.com/..." required autocomplete="off">
<button type="submit" id="submitBtn" class="btn btn-primary">Загрузить</button>
</div>
</form>
<div class="quick-actions">
<span>Быстрые действия:</span>
<button id="syncLikesBtn" class="btn btn-secondary">
❤️ Синхронизировать «Мне нравится» (Яндекс)
</button>
</div>
</section>
<!-- Active Tasks -->
<section class="card tasks-card">
<h2>⚡ Очередь задач</h2>
<div id="tasksList" class="tasks-list">
<p class="empty-state">Нет активных загрузок</p>
</div>
</section>
<!-- History -->
<section class="card history-card">
<h2>📜 Недавно загруженные треки</h2>
<div class="table-container">
<table class="tracks-table">
<thead>
<tr>
<th>Сервис</th>
<th>Исполнитель</th>
<th>Трек</th>
<th>Альбом</th>
<th>Дата</th>
</tr>
</thead>
<tbody id="historyBody">
<tr>
<td colspan="5" class="empty-state">Пока нет загрузок</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<footer class="footer">
<p>Navidrome Music Sync • Интегрировано с <a href="https://music.qpid.pro" target="_blank">music.qpid.pro</a></p>
</footer>
</div>
<script src="/static/app.js"></script>
</body>
</html>