feat(wave-v2): add LLM reranking, mood modes, auto-rotation scheduler and Navidrome auth

This commit is contained in:
Antigravity committed 2026-10-12 15:29:43 +03:00
1 parent 3b3d591bfd
commit fb1a5e01dd
11 files changed
+736 -37

No files matched your search

+10 -3
View File
@@ -22,17 +22,22 @@
- Определение цензуры: бейджи `🔞 18+ Explicit` (без цензуры) и `⚠️ Clean` (зацензуренная версия).
- Встроенный аудиоплеер предпрослушивания с возможностью перемотки на лету (**HTTP Range 206**).
- Прямое скачивание в браузер или добавление в очередь сервера в 1 клик.
- **🌊 Моя Волна (Smart Wave V1)**:
- **🔐 Защита & Авторизация через Navidrome**:
- Панель синхронизации защищена авторизацией. Для входа используются те же имя пользователя и пароль, что и в Navidrome.
- Сессионные токены HMAC (30 дней) для браузера и поддержка HTTP Basic Auth для API / автоматизации.
- **🌊 Моя Волна (Smart Wave V2)**:
- Автономный генератор персональных рекомендаций на основе профиля вкусов (звёздочки, скробблы, топ артистов из `navidrome.db`).
- **Контекстный LLM-реранкер**: локальная Ollama (`qwen2.5:1.5b` с `keep_alive: 0`, 0 МБ RAM в простое) анализирует расширенный пул кандидатов и отбирает треки под время суток (*утро / день / вечер / ночь*) или выбранное настроение (*Энергия*, *Чилл*, *Андеграунд*).
- Скользящий буфер на 25 треков в `/music/AI Recommendations/Моя Волна/` (~180 МБ).
- **Авто-ротация**: если трек лайкнут в Navidrome — переносится в основную библиотеку навсегда; если лежит более 7 дней без лайка — автоматически удаляется.
- **Фоновое расписание**: автоматическая ротация и пополнение буфера каждые 6 часов.
- Автоматическая генерация умного плейлиста `Моя Волна.nsp` и `Моя Волна.m3u8` для всех Subsonic клиентов.
- **Интеграция с Navidrome**:
- Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`.
- Автоматическое скачивание построчных караоке-текстов (`.lrc`).
- Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки.
- **Веб-интерфейс & REST API**:
- Адаптивный темный Web UI с прогресс-баром задач и историей загрузок.
- Адаптивный темный Web UI с прогресс-баром задач, переключателем настроения Волны и историей загрузок.
- Фоновый асинхронный воркер без блокировки интерфейса.
- Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке).
@@ -57,7 +62,9 @@ docker compose up -d --build
## 🔌 API Эндпоинты
- `GET /` — Веб-интерфейс управления.
- `GET /` — Веб-интерфейс управления (требуется авторизация).
- `GET /login` / `POST /login` — Страница и эндпоинт авторизации (валидация через Navidrome).
- `GET /logout` / `POST /logout` — Завершение сессии.
- `GET /api/search?q={query}` — Сквозной поиск по всем платформам с ранжированием.
- `GET /api/stream?url={url}` — Аудио-стриминг для плеера с поддержкой Range-запросов и перемотки.
- `GET /api/direct-download?url={url}` — Прямая отдача файла в браузер.
+14
View File
@@ -14,6 +14,20 @@ class Settings(BaseSettings):
HOST: str = "0.0.0.0"
PORT: int = 8005
# Authentication & Security
AUTH_ENABLED: bool = True
SECRET_KEY: str = "navidrome-sync-secret-key-2026-qpid"
ADMIN_PASSWORD: str = ""
NAVIDROME_INTERNAL_URL: str = "http://127.0.0.1:4533"
# Ollama LLM Settings
OLLAMA_URL: str = "http://127.0.0.1:11434"
OLLAMA_MODEL: str = "qwen2.5:1.5b"
# Wave Rotation
WAVE_AUTO_ROTATE_HOURS: int = 6
WAVE_CANDIDATE_POOL: int = 40
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
+110
View File
@@ -0,0 +1,110 @@
import hmac
import hashlib
import time
import json
import base64
import urllib.request
import urllib.error
from typing import Optional, Tuple
from fastapi import Request, HTTPException, status
from fastapi.responses import RedirectResponse
from app.config import settings
COOKIE_NAME = "sync_session"
SESSION_DURATION_DAYS = 30
def verify_credentials(username: str, password: str) -> bool:
"""Verify credentials against Navidrome (/auth/login) or fallback admin password."""
if not username or not password:
return False
# 1. Check fallback password if configured
if settings.ADMIN_PASSWORD and password == settings.ADMIN_PASSWORD:
return True
# 2. Check Navidrome native /auth/login endpoint
try:
url = f"{settings.NAVIDROME_INTERNAL_URL}/auth/login"
data = json.dumps({"username": username, "password": password}).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={
"Content-Type": "application/json",
"User-Agent": "NavidromeMusicSync/1.0"
}
)
with urllib.request.urlopen(req, timeout=5) as resp:
if resp.status == 200:
return True
except urllib.error.HTTPError as he:
# 401 Unauthorized or other auth errors
return False
except Exception as e:
print(f"[Auth] Navidrome auth check failed: {e}")
return False
def create_session_token(username: str, duration_days: int = SESSION_DURATION_DAYS) -> str:
"""Generate a signed HMAC token containing username and expiry timestamp."""
expiry = int(time.time()) + (duration_days * 86400)
payload = f"{username}:{expiry}"
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
def verify_session_token(token: str) -> Optional[str]:
"""Verify signed session token. Returns username if valid, None otherwise."""
if not token or ":" not in token:
return None
parts = token.split(":")
if len(parts) != 3:
return None
username, expiry_str, sig = parts
try:
expiry = int(expiry_str)
if time.time() > expiry:
return None
payload = f"{username}:{expiry}"
expected_sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
if hmac.compare_digest(sig, expected_sig):
return username
except Exception:
return None
return None
def get_authenticated_user(request: Request) -> Optional[str]:
"""Extract authenticated username from cookies, Basic Auth, or Bearer header."""
if not settings.AUTH_ENABLED:
return "anonymous"
# 1. Cookie session
token = request.cookies.get(COOKIE_NAME)
if token:
user = verify_session_token(token)
if user:
return user
# 2. HTTP Basic Auth
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Basic "):
try:
raw = base64.b64decode(auth_header[6:]).decode("utf-8")
if ":" in raw:
u, p = raw.split(":", 1)
if verify_credentials(u, p):
return u
except Exception:
pass
return None
def require_auth(request: Request) -> str:
"""FastAPI dependency to require authentication on protected endpoints."""
user = get_authenticated_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required. Please log in.",
headers={"WWW-Authenticate": "Basic realm=\"Navidrome Music Sync\""}
)
return user
+112 -21
View File
@@ -1,9 +1,10 @@
import os
import asyncio
import urllib.parse
from fastapi import FastAPI, Request, HTTPException, Query
from fastapi import FastAPI, Request, HTTPException, Query, Form, Depends, status
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
@@ -12,6 +13,13 @@ from app.db import init_db, get_recent_tracks, get_tracks_count, get_db
from app.queue import sync_manager
from app.core.search import search_all
from app.core.tagger import get_track_dest_path
from app.core.auth import (
verify_credentials,
create_session_token,
get_authenticated_user,
require_auth,
COOKIE_NAME
)
from app.wave.rotator import wave_rotator
from app.wave.playlist import ensure_smart_playlist
@@ -25,6 +33,23 @@ templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
class DownloadRequest(BaseModel):
url: str
class WaveGenerateRequest(BaseModel):
mood: Optional[str] = "auto"
async def _wave_auto_rotation_loop():
"""Background loop to periodically rotate and refill Smart Wave buffer."""
while True:
try:
# Sleep interval in hours
await asyncio.sleep(settings.WAVE_AUTO_ROTATE_HOURS * 3600)
print(f"[AutoRotate] Running scheduled Wave rotation & refill (every {settings.WAVE_AUTO_ROTATE_HOURS}h)...")
await wave_rotator.fill_buffer(mood="auto")
except asyncio.CancelledError:
break
except Exception as e:
print(f"[AutoRotate] Error during scheduled rotation: {e}")
await asyncio.sleep(300)
@app.on_event("startup")
def on_startup():
init_db()
@@ -33,18 +58,71 @@ def on_startup():
except Exception as e:
print(f"Warning: Failed to ensure smart playlist: {e}")
# Launch background auto-rotation task
if settings.WAVE_AUTO_ROTATE_HOURS > 0:
asyncio.create_task(_wave_auto_rotation_loop())
def get_prefix(request: Request) -> str:
"""Return URL prefix when hosted behind reverse proxy."""
prefix = request.headers.get("x-forwarded-prefix", "")
if prefix:
return prefix.rstrip("/")
if "music.qpid.pro" in request.headers.get("host", ""):
return "/sync"
return ""
# --- Authentication Routes ---
@app.get("/login")
def login_page(request: Request):
if get_authenticated_user(request):
return RedirectResponse(url=f"{get_prefix(request)}/", status_code=303)
return templates.TemplateResponse(request=request, name="login.html")
@app.post("/login")
def handle_login(request: Request, username: str = Form(...), password: str = Form(...)):
user = username.strip()
pwd = password.strip()
if verify_credentials(user, pwd):
resp = RedirectResponse(url=f"{get_prefix(request)}/", status_code=303)
token = create_session_token(user)
resp.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=30 * 86400,
httponly=True,
samesite="lax",
path="/"
)
return resp
return templates.TemplateResponse(
request=request,
name="login.html",
context={"error": "Неверное имя пользователя или пароль Navidrome"}
)
@app.get("/logout")
@app.post("/logout")
def handle_logout(request: Request):
resp = RedirectResponse(url=f"{get_prefix(request)}/login", status_code=303)
resp.delete_cookie(COOKIE_NAME, path="/")
return resp
# --- Protected Views & API Endpoints ---
@app.get("/")
def read_root(request: Request):
return templates.TemplateResponse(request=request, name="index.html")
user = get_authenticated_user(request)
if not user:
return RedirectResponse(url=f"{get_prefix(request)}/login", status_code=303)
return templates.TemplateResponse(request=request, name="index.html", context={"user": user})
@app.get("/api/search")
async def search_music(q: str = Query(..., min_length=1)):
async def search_music(q: str = Query(..., min_length=1), user: str = Depends(require_auth)):
"""Search tracks and albums across Yandex, Apple Music and YouTube."""
results = await search_all(q.strip())
return results
@app.post("/api/download")
async def start_download(req: DownloadRequest):
async def start_download(req: DownloadRequest, user: str = Depends(require_auth)):
url = req.url.strip()
if not url:
raise HTTPException(status_code=400, detail="URL is required")
@@ -53,7 +131,7 @@ async def start_download(req: DownloadRequest):
return {"task_id": task.id, "status": task.status}
@app.get("/api/direct-download")
async def direct_download(url: str = Query(...)):
async def direct_download(url: str = Query(...), user: str = Depends(require_auth)):
"""Download track directly to user's browser/device."""
provider = sync_manager.get_provider(url)
tracks = await provider.resolve(url)
@@ -75,19 +153,27 @@ async def direct_download(url: str = Query(...)):
if not success and track.service == "yandex":
success = await sync_manager.universal_provider.download_track(track, dest_path)
if not success:
raise HTTPException(status_code=500, detail="Failed to download track")
raise HTTPException(status_code=500, detail="Failed to fetch track audio")
filename = f"{track.artist} - {track.title}.mp3"
encoded_filename = urllib.parse.quote(filename)
quoted_filename = urllib.parse.quote(filename)
return FileResponse(
dest_path,
media_type="audio/mpeg",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted_filename}"
}
)
@app.get("/api/stream")
async def stream_audio(url: str = Query(...)):
async def stream_audio(request: Request, url: str = Query(...)):
"""Stream audio track for in-browser playback and seeking with HTTP Range support."""
# Ensure user has access
user = get_authenticated_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
# Local file preview (e.g. Wave buffer tracks)
if url.startswith("/") and os.path.exists(url) and os.path.realpath(url).startswith(os.path.realpath(settings.MUSIC_DIR)):
return FileResponse(
url,
@@ -127,7 +213,7 @@ async def stream_audio(url: str = Query(...)):
)
@app.post("/api/sync/yandex-likes")
async def sync_yandex_likes():
async def sync_yandex_likes(user: str = Depends(require_auth)):
if not settings.YANDEX_MUSIC_TOKEN:
raise HTTPException(status_code=400, detail="Yandex Music Token is not configured.")
@@ -135,39 +221,44 @@ async def sync_yandex_likes():
return {"task_id": task.id, "status": task.status}
@app.get("/api/tasks")
def list_tasks():
def list_tasks(user: str = Depends(require_auth)):
return list(sync_manager.tasks.values())
@app.get("/api/tasks/{task_id}")
def get_task(task_id: str):
def get_task(task_id: str, user: str = Depends(require_auth)):
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):
def get_history(limit: int = 50, user: str = Depends(require_auth)):
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)}
return {
"status": "ok",
"yandex_configured": bool(settings.YANDEX_MUSIC_TOKEN),
"auth_enabled": settings.AUTH_ENABLED
}
# --- Smart Wave Endpoints ---
# --- Smart Wave Endpoints (Phase 2) ---
@app.get("/api/wave/status")
def get_wave_status():
def get_wave_status(user: str = Depends(require_auth)):
"""Return status of Smart Wave buffer."""
return wave_rotator.get_status()
@app.post("/api/wave/generate")
async def generate_wave():
"""Discover candidates and fill Smart Wave buffer up to capacity."""
result = await wave_rotator.fill_buffer()
async def generate_wave(req: Optional[WaveGenerateRequest] = None, user: str = Depends(require_auth)):
"""Discover candidates and fill Smart Wave buffer up to capacity using LLM reranker."""
mood = req.mood if req else "auto"
result = await wave_rotator.fill_buffer(mood=mood)
return result
@app.post("/api/wave/rotate")
def rotate_wave():
def rotate_wave(user: str = Depends(require_auth)):
"""Promote starred tracks and purge expired tracks in Smart Wave buffer."""
promoted = wave_rotator.promote_starred_tracks()
purged = wave_rotator.purge_expired_tracks()
+31 -3
View File
@@ -307,17 +307,31 @@ document.addEventListener("DOMContentLoaded", () => {
const waveLoadingText = document.getElementById("waveLoadingText");
const waveGenerateBtn = document.getElementById("waveGenerateBtn");
const waveRotateBtn = document.getElementById("waveRotateBtn");
const waveMoodSelect = document.getElementById("waveMoodSelect");
const waveContextLabel = document.getElementById("waveContextLabel");
fetchWaveStatus();
if (waveGenerateBtn) {
waveGenerateBtn.addEventListener("click", async () => {
waveGenerateBtn.disabled = true;
if (waveLoadingText) waveLoadingText.style.display = "inline-block";
const mood = waveMoodSelect ? waveMoodSelect.value : "auto";
if (waveLoadingText) {
waveLoadingText.innerHTML = `<span class="spinner-sm"></span> Qwen 2.5 отбирает треки под вайб...`;
waveLoadingText.style.display = "inline-block";
}
try {
const res = await fetch("api/wave/generate", { method: "POST" });
const res = await fetch("api/wave/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mood: mood })
});
if (res.status === 401) {
window.location.href = "login";
return;
}
const data = await res.json();
alert(`Волна обновлена! Добавлено: ${data.added_count || 0} треков.`);
alert(`🌊 Волна обновлена под настроение [${data.mood_label || mood}]!\nДобавлено новых треков: ${data.added_count || 0}.`);
await fetchWaveStatus();
} catch (err) {
alert("Ошибка генерации волны: " + err);
@@ -333,6 +347,10 @@ document.addEventListener("DOMContentLoaded", () => {
waveRotateBtn.disabled = true;
try {
const res = await fetch("api/wave/rotate", { method: "POST" });
if (res.status === 401) {
window.location.href = "login";
return;
}
const data = await res.json();
const promMsg = (data.promoted_count > 0) ? `⭐ Перенесено в библиотеку: ${data.promoted_count}` : "Нет лайкнутых для переноса";
const purMsg = (data.purged_count > 0) ? `🗑️ Удалено устаревших: ${data.purged_count}` : "Нет устаревших";
@@ -349,6 +367,10 @@ document.addEventListener("DOMContentLoaded", () => {
async function fetchWaveStatus() {
try {
const res = await fetch("api/wave/status");
if (res.status === 401) {
window.location.href = "login";
return;
}
const data = await res.json();
renderWaveStatus(data);
} catch (err) {
@@ -360,6 +382,12 @@ document.addEventListener("DOMContentLoaded", () => {
if (waveCountLabel) waveCountLabel.textContent = `${data.count || 0} / ${data.capacity || 25} треков`;
if (waveSizeLabel) waveSizeLabel.textContent = `${data.total_size_mb || 0} МБ`;
// Context label
if (waveContextLabel && data.current_context) {
waveContextLabel.textContent = data.current_context.label || "🕒 Авто";
waveContextLabel.title = data.current_context.desc || "";
}
// Anchors
if (waveAnchorsContainer && waveAnchorsList) {
const anchors = data.anchor_artists || [];
+80
View File
@@ -731,3 +731,83 @@ input[type="text"]:focus {
vertical-align: middle;
margin-right: 0.3rem;
}
/* Auth & User Profile */
.badge-user {
background-color: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
border: 1px solid rgba(99, 102, 241, 0.4);
}
.btn-logout {
background-color: rgba(239, 68, 68, 0.15);
color: #fca5a5;
border: 1px solid rgba(239, 68, 68, 0.3);
padding: 0.25rem 0.6rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.78rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-logout:hover {
background-color: rgba(239, 68, 68, 0.3);
color: #ffffff;
}
/* Phase 2: LLM & Mood */
.badge-llm {
background-color: rgba(168, 85, 247, 0.2);
color: #c084fc;
border: 1px solid rgba(168, 85, 247, 0.4);
}
.wave-mood-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 0.8rem 0;
flex-wrap: wrap;
background-color: rgba(15, 23, 42, 0.5);
padding: 0.6rem 0.8rem;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.wave-mood-group {
display: flex;
align-items: center;
gap: 0.6rem;
}
.wave-mood-label {
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
}
.wave-mood-select {
background-color: #0f172a;
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.35rem 0.7rem;
font-size: 0.85rem;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.wave-mood-select:focus {
border-color: var(--accent-color);
}
.badge-context {
background-color: rgba(14, 165, 233, 0.15);
color: #38bdf8;
border: 1px solid rgba(14, 165, 233, 0.35);
font-size: 0.8rem;
}
+22 -2
View File
@@ -22,6 +22,10 @@
<div class="status-badges">
<span class="badge badge-success" id="yandexStatus">Яндекс Музыка: Активна</span>
<span class="badge badge-info" id="tracksCount">Загружено: 0</span>
{% if user %}
<span class="badge badge-user">👤 {{ user }}</span>
<a href="/logout" class="btn-logout" title="Выйти из системы">Выйти 🚪</a>
{% endif %}
</div>
</header>
@@ -74,9 +78,9 @@
<section class="card wave-card">
<div class="wave-header">
<div class="wave-header-title">
<h2>🌊 Моя Волна <span class="badge badge-wave">Smart Wave V1</span></h2>
<h2>🌊 Моя Волна <span class="badge badge-wave">Smart Wave V2</span> <span class="badge badge-llm">🤖 Qwen 2.5</span></h2>
<p class="wave-subtitle">
Персональный поток рекомендаций на основе ваших звёздочек и скробблов. Автоматическая ротация буфера в Navidrome.
Персональный контекстный поток рекомендаций. Нейросетевой реранкинг по времени суток и настроению с авто-ротацией в Navidrome.
</p>
</div>
<div class="wave-controls">
@@ -85,6 +89,22 @@
</div>
</div>
<!-- Mood & Context Selector Row -->
<div class="wave-mood-row">
<div class="wave-mood-group">
<label for="waveMoodSelect" class="wave-mood-label">Режим настроения:</label>
<select id="waveMoodSelect" class="wave-mood-select">
<option value="auto" selected>🕒 Авто (по времени суток)</option>
<option value="energy">⚡ Энергия & Спорт</option>
<option value="chill">☕ Спокойствие & Чилл</option>
<option value="dark">🌙 Ночной андеграунд</option>
</select>
</div>
<div class="wave-context-badge-wrap">
<span id="waveContextLabel" class="badge badge-context">Определение контекста...</span>
</div>
</div>
<div class="wave-stats-row">
<div class="wave-stat">
<span class="wave-stat-label">Буфер ротации:</span>
+140
View File
@@ -0,0 +1,140 @@
<!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="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="static/style.css">
<style>
.login-wrapper {
min-height: 80vh;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.login-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 2.5rem 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.login-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.3rem;
}
.login-subtitle {
font-size: 0.88rem;
color: var(--text-secondary);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.4rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background: #0f172a;
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
}
.form-input:focus {
border-color: var(--accent-color);
}
.btn-submit {
width: 100%;
padding: 0.85rem;
background: var(--accent-color);
color: #0f172a;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
margin-top: 0.5rem;
}
.btn-submit:hover {
background: var(--accent-hover);
}
.btn-submit:active {
transform: scale(0.99);
}
.error-banner {
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--danger-color);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1.25rem;
text-align: center;
}
.login-footer {
text-align: center;
margin-top: 1.75rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div class="login-wrapper">
<div class="login-card">
<div class="login-header">
<div class="login-logo">🎵</div>
<h1 class="login-title">Navidrome Sync</h1>
<p class="login-subtitle">Авторизация через учётную запись Navidrome</p>
</div>
{% if error %}
<div class="error-banner">
{{ error }}
</div>
{% endif %}
<form action="login" method="POST">
<div class="form-group">
<label class="form-label" for="username">Имя пользователя (Логин)</label>
<input class="form-input" type="text" id="username" name="username" placeholder="kopri" required autofocus>
</div>
<div class="form-group">
<label class="form-label" for="password">Пароль</label>
<input class="form-input" type="password" id="password" name="password" placeholder="••••••••" required>
</div>
<button type="submit" class="btn-submit">Войти в панель</button>
</form>
<div class="login-footer">
🔐 Доступ защищён паролем от вашей медиатеки Navidrome.
</div>
</div>
</div>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
import json
import re
import time
from datetime import datetime
import urllib.request
import urllib.error
from typing import List, Dict, Any, Optional
from app.config import settings
from app.providers.base import TrackMetadata
class WaveLlmReranker:
def __init__(self, ollama_url: Optional[str] = None, model: Optional[str] = None):
self.ollama_url = (ollama_url or settings.OLLAMA_URL).rstrip("/")
self.model = model or settings.OLLAMA_MODEL
def get_time_of_day_context(self) -> Dict[str, str]:
"""Detect current time of day and musical context."""
hour = datetime.now().hour
if 5 <= hour < 12:
return {
"period": "утро",
"label": "🌅 Утренний чилл",
"desc": "мягкое плавное пробуждение, мелодичный чилл, акустическое или лёгкое звучание"
}
elif 12 <= hour < 18:
return {
"period": "день",
"label": "☀️ Дневной фокус",
"desc": "бодрый дневной ритм, концентрация, динамичный и сбалансированный флоу"
}
elif 18 <= hour < 23:
return {
"period": "вечер",
"label": "🌆 Вечерний вайб",
"desc": "атмосферный глубокий звук, эмоциональные треки, плотный бас и хип-хоп/рок"
}
else:
return {
"period": "ночь",
"label": "🌙 Ночной андеграунд",
"desc": "ночной драйв, меланхолия, глубокий бас, lo-fi и атмосферный андеграунд"
}
def resolve_mood_context(self, mood: str) -> Dict[str, str]:
"""Map user-selected mood string to descriptive context."""
mood_lower = (mood or "auto").lower()
if mood_lower == "energy":
return {
"period": "драйв",
"label": "⚡ Энергия & Спорт",
"desc": "взрывной ритм, максимальная динамика, бодрящий кач и быстрый темп"
}
elif mood_lower == "chill":
return {
"period": "релакс",
"label": "☕ Спокойствие & Чилл",
"desc": "расслабляющая музыка, мягкий ненавязчивый ритм, плавные переходы"
}
elif mood_lower == "dark":
return {
"period": "андеграунд",
"label": "🏴 Мрачный вайб",
"desc": "мрачный звук, глубокий саб-бас, андеграундный рэп и сырой плотный саунд"
}
# Default: auto by time of day
return self.get_time_of_day_context()
def rerank_candidates(
self,
candidates: List[TrackMetadata],
anchor_profile: Dict[str, Any],
mood: str = "auto",
target_count: int = 25
) -> List[TrackMetadata]:
"""
Rerank and select candidates using Ollama qwen2.5 model.
Falls back smoothly to heuristic ranking if Ollama is unreachable or times out.
"""
if not candidates:
return []
if len(candidates) <= target_count:
return candidates
context = self.resolve_mood_context(mood)
# Build compact candidate list representation
# Format: 1. Artist - Title
candidate_lines = []
for idx, c in enumerate(candidates, 1):
candidate_lines.append(f"{idx}. {c.artist} - {c.title}")
candidates_text = "\n".join(candidate_lines)
# Extract anchor artist names for context
top_artists = [a.get("artist") for a in anchor_profile.get("top_artists", [])[:5] if a.get("artist")]
artists_str = ", ".join(top_artists) if top_artists else "Разнообразный русский и мировой рэп/рок"
prompt = (
f"Ты — опытный музыкальный куратор радио.\n"
f"Контекст: {context['label']} ({context['desc']}).\n"
f"Любимые артисты пользователя: {artists_str}.\n\n"
f"Список кандидатов:\n{candidates_text}\n\n"
f"Задача: выбери ровно {target_count} номеров треков из списка, которые лучше всего подходят под этот вайб "
f"и обеспечат гармоничную последовательность прослушивания.\n"
f"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON списка номеров, например: [1, 5, 8, 12, ...]. Без лишнего текста."
)
print(f"[WaveLLM] Sourcing {target_count} tracks via {self.model} for mood '{context['label']}'...")
try:
req_data = json.dumps({
"model": self.model,
"prompt": prompt,
"stream": False,
"keep_alive": 0,
"options": {
"temperature": 0.25,
"num_predict": 150
}
}).encode("utf-8")
req = urllib.request.Request(
f"{self.ollama_url}/api/generate",
data=req_data,
headers={"Content-Type": "application/json"}
)
# 45 second timeout for local CPU inference
with urllib.request.urlopen(req, timeout=45) as resp:
resp_json = json.loads(resp.read().decode("utf-8"))
raw_response = resp_json.get("response", "").strip()
print(f"[WaveLLM] Model output: {raw_response[:100]}...")
# Extract JSON list of indices from response
match = re.search(r"\[([0-9,\s]+)\]", raw_response)
if match:
selected_indices = [int(x.strip()) for x in match.group(1).split(",") if x.strip().isdigit()]
# Filter valid 1-based indices
valid_tracks = []
seen_indices = set()
for idx in selected_indices:
if 1 <= idx <= len(candidates) and idx not in seen_indices:
seen_indices.add(idx)
valid_tracks.append(candidates[idx - 1])
if len(valid_tracks) >= min(5, target_count):
# If model returned fewer than target_count, fill remaining from unselected
if len(valid_tracks) < target_count:
for idx, c in enumerate(candidates, 1):
if idx not in seen_indices:
valid_tracks.append(c)
if len(valid_tracks) >= target_count:
break
print(f"[WaveLLM] Successfully reranked {len(valid_tracks)} tracks using {self.model}")
return valid_tracks[:target_count]
except Exception as e:
print(f"[WaveLLM] Notice: Ollama reranker fallback ({e}). Applying heuristic selection.")
# Deadair Fallback Principle: Heuristic selection
# Interleave tracks from diverse artists
artist_buckets: Dict[str, List[TrackMetadata]] = {}
for c in candidates:
artist_buckets.setdefault(c.artist, []).append(c)
heuristic_tracks: List[TrackMetadata] = []
while len(heuristic_tracks) < target_count and any(artist_buckets.values()):
for artist, tracks in list(artist_buckets.items()):
if tracks and len(heuristic_tracks) < target_count:
heuristic_tracks.append(tracks.pop(0))
return heuristic_tracks[:target_count]
+41 -6
View File
@@ -8,6 +8,7 @@ from app.wave.recommender import WaveRecommender
from app.wave.playlist import ensure_smart_playlist
from app.core.tagger import get_track_dest_path
from app.core.lyrics import fetch_and_save_lyrics
from app.wave.llm_reranker import WaveLlmReranker
from app.providers.yandex import YandexMusicProvider
from app.providers.universal import UniversalProvider
@@ -28,6 +29,7 @@ class WaveRotator:
self.yandex_provider = YandexMusicProvider()
self.universal_provider = UniversalProvider()
self.recommender = WaveRecommender(self.yandex_provider)
self.reranker = WaveLlmReranker()
def get_current_buffer(self) -> List[Dict[str, Any]]:
"""List all audio files currently in the Wave rotation buffer."""
@@ -134,8 +136,8 @@ class WaveRotator:
return purged
async def fill_buffer(self) -> Dict[str, Any]:
"""Run rotation (promotion + purge) and download candidates up to target capacity."""
async def fill_buffer(self, mood: str = "auto", refresh_count: int = 5) -> Dict[str, Any]:
"""Run rotation (promotion + purge) and download candidates up to target capacity using LLM reranker."""
promoted = self.promote_starred_tracks()
purged = self.purge_expired_tracks()
@@ -143,22 +145,50 @@ class WaveRotator:
current_count = len(current_items)
needed = max(0, self.target_capacity - current_count)
# If buffer is full and user requests generation, rotate out oldest unstarred tracks to inject fresh recommendations
if needed == 0 and refresh_count > 0:
sorted_by_age = sorted(current_items, key=lambda x: x["mtime"])
to_remove = sorted_by_age[:refresh_count]
for item in to_remove:
try:
os.remove(item["path"])
lrc = os.path.splitext(item["path"])[0] + ".lrc"
if os.path.exists(lrc):
os.remove(lrc)
print(f"[WaveRotator] Rotating out oldest wave track: {item['filename']}")
except Exception as e:
print(f"[WaveRotator] Failed to remove {item['filename']}: {e}")
current_items = self.get_current_buffer()
current_count = len(current_items)
needed = max(0, self.target_capacity - current_count)
added: List[Dict[str, str]] = []
context = self.reranker.resolve_mood_context(mood)
if needed > 0:
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing {needed} new tracks...")
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing candidates for {needed} slots (mood: {context['label']})...")
profile = self.collector.get_anchor_profile()
existing_keys = self.collector.get_existing_track_keys()
wave_keys = self.get_wave_track_keys()
candidates = self.recommender.get_candidate_tracks(
# Source a larger candidate pool for LLM contextual reranking
candidate_pool_size = max(needed * 2, settings.WAVE_CANDIDATE_POOL)
raw_candidates = self.recommender.get_candidate_tracks(
anchor_profile=profile,
existing_keys=existing_keys,
wave_keys=wave_keys,
target_count=candidate_pool_size
)
# Contextual LLM Reranker (Qwen 2.5 / deadair fallback)
selected_candidates = self.reranker.rerank_candidates(
candidates=raw_candidates,
anchor_profile=profile,
mood=mood,
target_count=needed
)
for cand in candidates:
for cand in selected_candidates:
filename = f"{cand.artist} - {cand.title}.mp3"
# Clean unsafe filesystem chars
filename = "".join(c for c in filename if c not in '<>:"/\\|?*')
@@ -187,6 +217,8 @@ class WaveRotator:
return {
"target_capacity": self.target_capacity,
"total_in_buffer": len(self.get_current_buffer()),
"mood": mood,
"mood_label": context["label"],
"added_count": len(added),
"added_tracks": added,
"promoted_count": len(promoted),
@@ -201,6 +233,7 @@ class WaveRotator:
total_size_mb = round(sum(t["size_mb"] for t in buffer_tracks), 2)
profile = self.collector.get_anchor_profile()
anchor_artists = [a["artist"] for a in profile.get("top_artists", [])[:6]]
context = self.reranker.resolve_mood_context("auto")
return {
"capacity": self.target_capacity,
@@ -208,7 +241,9 @@ class WaveRotator:
"total_size_mb": total_size_mb,
"expire_days": self.expire_days,
"anchor_artists": anchor_artists,
"tracks": buffer_tracks[:15]
"current_context": context,
"llm_model": settings.OLLAMA_MODEL,
"tracks": buffer_tracks[:25]
}
wave_rotator = WaveRotator()
+1 -2
View File
@@ -3,8 +3,7 @@ services:
build: .
container_name: navidrome-music-sync
restart: unless-stopped
ports:
- "127.0.0.1:8005:8005"
network_mode: host
volumes:
- /home/ai-projects/services/music.qpid.pro/music:/music
- /home/ai-projects/services/music.qpid.pro/data:/navidrome-data:ro