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 @@
|
||||
"""Navidrome Music Sync Package"""
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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)}
|
||||
@@ -0,0 +1 @@
|
||||
"""Music streaming providers"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user