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,24 @@
|
|||||||
|
# Navidrome Music Sync Configuration
|
||||||
|
|
||||||
|
# Yandex Music OAuth Token
|
||||||
|
YANDEX_MUSIC_TOKEN=
|
||||||
|
|
||||||
|
# Directory where music library is stored (mapped to Navidrome library)
|
||||||
|
MUSIC_DIR=/music
|
||||||
|
|
||||||
|
# Data directory for SQLite database and temporary files
|
||||||
|
DATA_DIR=/app/data
|
||||||
|
|
||||||
|
# Navidrome Subsonic API integration (for auto-rescanning library after downloads)
|
||||||
|
NAVIDROME_URL=https://music.qpid.pro
|
||||||
|
NAVIDROME_USER=
|
||||||
|
NAVIDROME_PASSWORD=
|
||||||
|
|
||||||
|
# Audio settings
|
||||||
|
DOWNLOAD_LYRICS=true
|
||||||
|
AUDIO_FORMAT=mp3
|
||||||
|
AUDIO_BITRATE=320k
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
HOST=0.0.0.0
|
||||||
|
PORT=8005
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
music/
|
||||||
|
downloads/
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Install system dependencies (ffmpeg is required for audio extraction & conversion)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ffmpeg \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create volume mountpoints
|
||||||
|
RUN mkdir -p /music /app/data
|
||||||
|
|
||||||
|
EXPOSE 8005
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8005"]
|
||||||
@@ -1,3 +1,56 @@
|
|||||||
# navidrome-music-sync
|
# 🎵 Navidrome Music Sync
|
||||||
|
|
||||||
Multi-source music importer & downloader for Navidrome (Yandex Music, Spotify, Apple Music, YouTube Music)
|
Мультисервисный импортер и синхронизатор музыки для **Navidrome**.
|
||||||
|
|
||||||
|
Позволяет в один клик или по расписанию импортировать треки, альбомы и плейлисты из **Яндекс Музыки**, **Spotify**, **Apple Music** и **YouTube Music** напрямую в медиатеку Navidrome с сохранением оригинальных тегов, обложек в высоком разрешении и синхронизированных караоке-текстов (`.lrc`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Возможности
|
||||||
|
|
||||||
|
- **Прямой импорт из Яндекс Музыки (320 kbps)**:
|
||||||
|
- Скачивание отдельных треков, альбомов и плейлистов.
|
||||||
|
- Полная синхронизация личного плейлиста **«Мне нравится»** (880+ треков).
|
||||||
|
- **Импорт из Spotify и Apple Music**:
|
||||||
|
- Без необходимости платных подписок и зарубежных аккаунтов.
|
||||||
|
- Извлечение точных метаданных через официальные открытые каталоги и аудио-матчинг с YouTube Music CDN (Opus 160 / MP3 320k).
|
||||||
|
- **Обход цензуры и региональных блокировок**:
|
||||||
|
- Если трек в каталоге РФ заблокирован или зацензурен («запикан»), движок автоматически переключается на глобальные источники и скачивает полноценную **Explicit (18+)** версию.
|
||||||
|
- **Интеграция с Navidrome**:
|
||||||
|
- Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`.
|
||||||
|
- Автоматическое скачивание построчных караоке-текстов (`.lrc`).
|
||||||
|
- Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки.
|
||||||
|
- **Веб-интерфейс & REST API**:
|
||||||
|
- Адаптивный темный Web UI с прогресс-баром задач и историей загрузок.
|
||||||
|
- Фоновый асинхронный воркер без блокировки интерфейса.
|
||||||
|
- Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Установка и запуск
|
||||||
|
|
||||||
|
### 1. Конфигурация (.env)
|
||||||
|
Скопируйте пример конфигурации и укажите токен Яндекс Музыки:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Запуск через Docker Compose
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Сервис будет доступен по адресу: `http://127.0.0.1:8005` (или через обратный прокси).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 API Эндпоинты
|
||||||
|
|
||||||
|
- `GET /` — Веб-интерфейс управления.
|
||||||
|
- `POST /api/download` — Запуск задачи на скачивание по ссылке:
|
||||||
|
```json
|
||||||
|
{ "url": "https://music.yandex.ru/album/123/track/456" }
|
||||||
|
```
|
||||||
|
- `POST /api/sync/yandex-likes` — Запуск полной синхронизации понравившихся треков из Яндекс Музыки.
|
||||||
|
- `GET /api/tasks` — Список активных задач и прогресс выполнения.
|
||||||
|
- `GET /api/history` — История и статистика загруженной музыки.
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
sync:
|
||||||
|
build: .
|
||||||
|
container_name: navidrome-music-sync
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8005:8005"
|
||||||
|
volumes:
|
||||||
|
- /home/ai-projects/services/music.qpid.pro/music:/music
|
||||||
|
- ./data:/app/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
pydantic>=2.9.0
|
||||||
|
pydantic-settings>=2.5.0
|
||||||
|
yandex-music>=3.2.0
|
||||||
|
yt-dlp>=2024.10.0
|
||||||
|
spotdl>=4.2.0
|
||||||
|
mutagen>=1.47.0
|
||||||
|
syncedlyrics>=0.10.0
|
||||||
|
aiofiles>=24.1.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
jinja2>=3.1.0
|
||||||
|
python-multipart>=0.0.12
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import sys, os, time
|
||||||
|
from yandex_music import Client
|
||||||
|
from yandex_music.exceptions import NetworkError
|
||||||
|
|
||||||
|
c = Client()
|
||||||
|
code = c.request_device_code()
|
||||||
|
|
||||||
|
print("="*50, flush=True)
|
||||||
|
print(f"URL: {code.verification_url}", flush=True)
|
||||||
|
print(f"CODE: {code.user_code}", flush=True)
|
||||||
|
print("="*50, flush=True)
|
||||||
|
|
||||||
|
with open("/tmp/ya_device_status.txt", "w") as f:
|
||||||
|
f.write(f"URL: {code.verification_url}\nCODE: {code.user_code}\n")
|
||||||
|
|
||||||
|
deadline = time.monotonic() + code.expires_in
|
||||||
|
interval = max(code.interval or 5, 6)
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
token_obj = c.poll_device_token(code.device_code)
|
||||||
|
if token_obj is not None:
|
||||||
|
token = token_obj.access_token
|
||||||
|
print(f"SUCCESS_TOKEN: {token}", flush=True)
|
||||||
|
with open("/tmp/ya_token.txt", "w") as f:
|
||||||
|
f.write(token)
|
||||||
|
|
||||||
|
# Read and update credentials.txt
|
||||||
|
cred_path = "/home/ai-projects/memory/credentials.txt"
|
||||||
|
if os.path.exists(cred_path):
|
||||||
|
with open(cred_path, "r") as cf:
|
||||||
|
content = cf.read()
|
||||||
|
if "yandex_music_token:" not in content:
|
||||||
|
content += f"\n# Yandex Music Token\nyandex_music_token: {token}\n"
|
||||||
|
with open(cred_path, "w") as cf:
|
||||||
|
cf.write(content)
|
||||||
|
print("CREDENTIALS_UPDATED", flush=True)
|
||||||
|
sys.exit(0)
|
||||||
|
except NetworkError as e:
|
||||||
|
if "429" in str(e):
|
||||||
|
print("Got 429 rate limit, sleeping 10s...", flush=True)
|
||||||
|
time.sleep(10)
|
||||||
|
continue
|
||||||
|
print(f"Network error: {e}", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error polling: {e}", flush=True)
|
||||||
|
|
||||||
|
time.sleep(interval)
|
||||||
|
|
||||||
|
print("TIMED_OUT", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
Reference in new issue
Block a user