diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2b5b93a
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a8b8583
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.env
+data/
+music/
+downloads/
+*.db
+*.log
+.venv/
+venv/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..5e97f0f
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index 85d97f7..f4e7a2c 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,56 @@
-# navidrome-music-sync
+# π΅ Navidrome Music Sync
-Multi-source music importer & downloader for Navidrome (Yandex Music, Spotify, Apple Music, YouTube Music)
\ No newline at end of file
+ΠΡΠ»ΡΡΠΈΡΠ΅ΡΠ²ΠΈΡΠ½ΡΠΉ ΠΈΠΌΠΏΠΎΡΡΠ΅Ρ ΠΈ ΡΠΈΠ½Ρ
ΡΠΎΠ½ΠΈΠ·Π°ΡΠΎΡ ΠΌΡΠ·ΡΠΊΠΈ Π΄Π»Ρ **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` β ΠΡΡΠΎΡΠΈΡ ΠΈ ΡΡΠ°ΡΠΈΡΡΠΈΠΊΠ° Π·Π°Π³ΡΡΠΆΠ΅Π½Π½ΠΎΠΉ ΠΌΡΠ·ΡΠΊΠΈ.
\ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..a33f794
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1 @@
+"""Navidrome Music Sync Package"""
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..2d6e9fe
--- /dev/null
+++ b/app/config.py
@@ -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)
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..811a7c9
--- /dev/null
+++ b/app/core/__init__.py
@@ -0,0 +1 @@
+"""Core modules for tagging, lyrics, and navidrome"""
diff --git a/app/core/lyrics.py b/app/core/lyrics.py
new file mode 100644
index 0000000..6c4c336
--- /dev/null
+++ b/app/core/lyrics.py
@@ -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
diff --git a/app/core/navidrome.py b/app/core/navidrome.py
new file mode 100644
index 0000000..30806e7
--- /dev/null
+++ b/app/core/navidrome.py
@@ -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
diff --git a/app/core/tagger.py b/app/core/tagger.py
new file mode 100644
index 0000000..ef1971e
--- /dev/null
+++ b/app/core/tagger.py
@@ -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}")
diff --git a/app/db.py b/app/db.py
new file mode 100644
index 0000000..2350830
--- /dev/null
+++ b/app/db.py
@@ -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
diff --git a/app/main.py b/app/main.py
new file mode 100644
index 0000000..5c4e815
--- /dev/null
+++ b/app/main.py
@@ -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)}
diff --git a/app/providers/__init__.py b/app/providers/__init__.py
new file mode 100644
index 0000000..82e35b9
--- /dev/null
+++ b/app/providers/__init__.py
@@ -0,0 +1 @@
+"""Music streaming providers"""
diff --git a/app/providers/base.py b/app/providers/base.py
new file mode 100644
index 0000000..d1e0cd7
--- /dev/null
+++ b/app/providers/base.py
@@ -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
diff --git a/app/providers/universal.py b/app/providers/universal.py
new file mode 100644
index 0000000..fd656b4
--- /dev/null
+++ b/app/providers/universal.py
@@ -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
diff --git a/app/providers/yandex.py b/app/providers/yandex.py
new file mode 100644
index 0000000..1e865aa
--- /dev/null
+++ b/app/providers/yandex.py
@@ -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
diff --git a/app/queue.py b/app/queue.py
new file mode 100644
index 0000000..be8c223
--- /dev/null
+++ b/app/queue.py
@@ -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()
diff --git a/app/static/app.js b/app/static/app.js
new file mode 100644
index 0000000..75477c1
--- /dev/null
+++ b/app/static/app.js
@@ -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 = `
ΠΠ΅Ρ Π°ΠΊΡΠΈΠ²Π½ΡΡ
Π·Π°Π³ΡΡΠ·ΠΎΠΊ
`;
+ return;
+ }
+
+ tasksList.innerHTML = tasks.map(t => {
+ const percent = t.total > 0 ? Math.round((t.progress / t.total) * 100) : 0;
+ return `
+
+
+
+
+ ${t.progress}/${t.total} (${percent}%) β’ ${escapeHtml(t.current_item || '')}
+
+
+ `;
+ }).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 = `| ΠΠΎΠΊΠ° Π½Π΅Ρ Π·Π°Π³ΡΡΠ·ΠΎΠΊ |
`;
+ return;
+ }
+
+ historyBody.innerHTML = tracks.map(t => `
+
+ | ${t.service} |
+ ${escapeHtml(t.artist)} |
+ ${escapeHtml(t.title)} |
+ ${escapeHtml(t.album || 'Singles')} |
+ ${formatDate(t.downloaded_at)} |
+
+ `).join("");
+ }
+
+ function escapeHtml(str) {
+ if (!str) return "";
+ return str.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' });
+ }
+});
diff --git a/app/static/style.css b/app/static/style.css
new file mode 100644
index 0000000..c899df2
--- /dev/null
+++ b/app/static/style.css
@@ -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;
+}
diff --git a/app/templates/index.html b/app/templates/index.html
new file mode 100644
index 0000000..686f5a7
--- /dev/null
+++ b/app/templates/index.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+ Navidrome Music Sync
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ β‘ ΠΡΠ΅ΡΠ΅Π΄Ρ Π·Π°Π΄Π°Ρ
+
+
ΠΠ΅Ρ Π°ΠΊΡΠΈΠ²Π½ΡΡ
Π·Π°Π³ΡΡΠ·ΠΎΠΊ
+
+
+
+
+
+ π ΠΠ΅Π΄Π°Π²Π½ΠΎ Π·Π°Π³ΡΡΠΆΠ΅Π½Π½ΡΠ΅ ΡΡΠ΅ΠΊΠΈ
+
+
+
+
+ | Π‘Π΅ΡΠ²ΠΈΡ |
+ ΠΡΠΏΠΎΠ»Π½ΠΈΡΠ΅Π»Ρ |
+ Π’ΡΠ΅ΠΊ |
+ ΠΠ»ΡΠ±ΠΎΠΌ |
+ ΠΠ°ΡΠ° |
+
+
+
+
+ | ΠΠΎΠΊΠ° Π½Π΅Ρ Π·Π°Π³ΡΡΠ·ΠΎΠΊ |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3c0e990
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..8e82515
--- /dev/null
+++ b/requirements.txt
@@ -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
diff --git a/scripts/get_yandex_token.py b/scripts/get_yandex_token.py
new file mode 100644
index 0000000..0b55eba
--- /dev/null
+++ b/scripts/get_yandex_token.py
@@ -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)