feat(wave-v2): add LLM reranking, mood modes, auto-rotation scheduler and Navidrome auth
This commit is contained in:
1 parent
3b3d591bfd
commit
fb1a5e01dd
11 files changed
+736
-37
No files matched your search
@@ -22,17 +22,22 @@
|
|||||||
- Определение цензуры: бейджи `🔞 18+ Explicit` (без цензуры) и `⚠️ Clean` (зацензуренная версия).
|
- Определение цензуры: бейджи `🔞 18+ Explicit` (без цензуры) и `⚠️ Clean` (зацензуренная версия).
|
||||||
- Встроенный аудиоплеер предпрослушивания с возможностью перемотки на лету (**HTTP Range 206**).
|
- Встроенный аудиоплеер предпрослушивания с возможностью перемотки на лету (**HTTP Range 206**).
|
||||||
- Прямое скачивание в браузер или добавление в очередь сервера в 1 клик.
|
- Прямое скачивание в браузер или добавление в очередь сервера в 1 клик.
|
||||||
- **🌊 Моя Волна (Smart Wave V1)**:
|
- **🔐 Защита & Авторизация через Navidrome**:
|
||||||
|
- Панель синхронизации защищена авторизацией. Для входа используются те же имя пользователя и пароль, что и в Navidrome.
|
||||||
|
- Сессионные токены HMAC (30 дней) для браузера и поддержка HTTP Basic Auth для API / автоматизации.
|
||||||
|
- **🌊 Моя Волна (Smart Wave V2)**:
|
||||||
- Автономный генератор персональных рекомендаций на основе профиля вкусов (звёздочки, скробблы, топ артистов из `navidrome.db`).
|
- Автономный генератор персональных рекомендаций на основе профиля вкусов (звёздочки, скробблы, топ артистов из `navidrome.db`).
|
||||||
|
- **Контекстный LLM-реранкер**: локальная Ollama (`qwen2.5:1.5b` с `keep_alive: 0`, 0 МБ RAM в простое) анализирует расширенный пул кандидатов и отбирает треки под время суток (*утро / день / вечер / ночь*) или выбранное настроение (*Энергия*, *Чилл*, *Андеграунд*).
|
||||||
- Скользящий буфер на 25 треков в `/music/AI Recommendations/Моя Волна/` (~180 МБ).
|
- Скользящий буфер на 25 треков в `/music/AI Recommendations/Моя Волна/` (~180 МБ).
|
||||||
- **Авто-ротация**: если трек лайкнут в Navidrome — переносится в основную библиотеку навсегда; если лежит более 7 дней без лайка — автоматически удаляется.
|
- **Авто-ротация**: если трек лайкнут в Navidrome — переносится в основную библиотеку навсегда; если лежит более 7 дней без лайка — автоматически удаляется.
|
||||||
|
- **Фоновое расписание**: автоматическая ротация и пополнение буфера каждые 6 часов.
|
||||||
- Автоматическая генерация умного плейлиста `Моя Волна.nsp` и `Моя Волна.m3u8` для всех Subsonic клиентов.
|
- Автоматическая генерация умного плейлиста `Моя Волна.nsp` и `Моя Волна.m3u8` для всех Subsonic клиентов.
|
||||||
- **Интеграция с Navidrome**:
|
- **Интеграция с Navidrome**:
|
||||||
- Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`.
|
- Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`.
|
||||||
- Автоматическое скачивание построчных караоке-текстов (`.lrc`).
|
- Автоматическое скачивание построчных караоке-текстов (`.lrc`).
|
||||||
- Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки.
|
- Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки.
|
||||||
- **Веб-интерфейс & REST API**:
|
- **Веб-интерфейс & REST API**:
|
||||||
- Адаптивный темный Web UI с прогресс-баром задач и историей загрузок.
|
- Адаптивный темный Web UI с прогресс-баром задач, переключателем настроения Волны и историей загрузок.
|
||||||
- Фоновый асинхронный воркер без блокировки интерфейса.
|
- Фоновый асинхронный воркер без блокировки интерфейса.
|
||||||
- Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке).
|
- Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке).
|
||||||
|
|
||||||
@@ -57,7 +62,9 @@ docker compose up -d --build
|
|||||||
|
|
||||||
## 🔌 API Эндпоинты
|
## 🔌 API Эндпоинты
|
||||||
|
|
||||||
- `GET /` — Веб-интерфейс управления.
|
- `GET /` — Веб-интерфейс управления (требуется авторизация).
|
||||||
|
- `GET /login` / `POST /login` — Страница и эндпоинт авторизации (валидация через Navidrome).
|
||||||
|
- `GET /logout` / `POST /logout` — Завершение сессии.
|
||||||
- `GET /api/search?q={query}` — Сквозной поиск по всем платформам с ранжированием.
|
- `GET /api/search?q={query}` — Сквозной поиск по всем платформам с ранжированием.
|
||||||
- `GET /api/stream?url={url}` — Аудио-стриминг для плеера с поддержкой Range-запросов и перемотки.
|
- `GET /api/stream?url={url}` — Аудио-стриминг для плеера с поддержкой Range-запросов и перемотки.
|
||||||
- `GET /api/direct-download?url={url}` — Прямая отдача файла в браузер.
|
- `GET /api/direct-download?url={url}` — Прямая отдача файла в браузер.
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ class Settings(BaseSettings):
|
|||||||
HOST: str = "0.0.0.0"
|
HOST: str = "0.0.0.0"
|
||||||
PORT: int = 8005
|
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:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
@@ -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
@@ -1,9 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
import urllib.parse
|
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.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Dict, Any, Optional
|
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.queue import sync_manager
|
||||||
from app.core.search import search_all
|
from app.core.search import search_all
|
||||||
from app.core.tagger import get_track_dest_path
|
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.rotator import wave_rotator
|
||||||
from app.wave.playlist import ensure_smart_playlist
|
from app.wave.playlist import ensure_smart_playlist
|
||||||
|
|
||||||
@@ -25,6 +33,23 @@ templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
|||||||
class DownloadRequest(BaseModel):
|
class DownloadRequest(BaseModel):
|
||||||
url: str
|
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")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
init_db()
|
init_db()
|
||||||
@@ -33,18 +58,71 @@ def on_startup():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Warning: Failed to ensure smart playlist: {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("/")
|
@app.get("/")
|
||||||
def read_root(request: Request):
|
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")
|
@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."""
|
"""Search tracks and albums across Yandex, Apple Music and YouTube."""
|
||||||
results = await search_all(q.strip())
|
results = await search_all(q.strip())
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@app.post("/api/download")
|
@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()
|
url = req.url.strip()
|
||||||
if not url:
|
if not url:
|
||||||
raise HTTPException(status_code=400, detail="URL is required")
|
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}
|
return {"task_id": task.id, "status": task.status}
|
||||||
|
|
||||||
@app.get("/api/direct-download")
|
@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."""
|
"""Download track directly to user's browser/device."""
|
||||||
provider = sync_manager.get_provider(url)
|
provider = sync_manager.get_provider(url)
|
||||||
tracks = await provider.resolve(url)
|
tracks = await provider.resolve(url)
|
||||||
@@ -75,19 +153,27 @@ async def direct_download(url: str = Query(...)):
|
|||||||
if not success and track.service == "yandex":
|
if not success and track.service == "yandex":
|
||||||
success = await sync_manager.universal_provider.download_track(track, dest_path)
|
success = await sync_manager.universal_provider.download_track(track, dest_path)
|
||||||
if not success:
|
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"
|
filename = f"{track.artist} - {track.title}.mp3"
|
||||||
encoded_filename = urllib.parse.quote(filename)
|
quoted_filename = urllib.parse.quote(filename)
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
dest_path,
|
dest_path,
|
||||||
media_type="audio/mpeg",
|
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")
|
@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."""
|
"""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)):
|
if url.startswith("/") and os.path.exists(url) and os.path.realpath(url).startswith(os.path.realpath(settings.MUSIC_DIR)):
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
url,
|
url,
|
||||||
@@ -127,7 +213,7 @@ async def stream_audio(url: str = Query(...)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@app.post("/api/sync/yandex-likes")
|
@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:
|
if not settings.YANDEX_MUSIC_TOKEN:
|
||||||
raise HTTPException(status_code=400, detail="Yandex Music Token is not configured.")
|
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}
|
return {"task_id": task.id, "status": task.status}
|
||||||
|
|
||||||
@app.get("/api/tasks")
|
@app.get("/api/tasks")
|
||||||
def list_tasks():
|
def list_tasks(user: str = Depends(require_auth)):
|
||||||
return list(sync_manager.tasks.values())
|
return list(sync_manager.tasks.values())
|
||||||
|
|
||||||
@app.get("/api/tasks/{task_id}")
|
@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:
|
if task_id not in sync_manager.tasks:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return sync_manager.tasks[task_id]
|
return sync_manager.tasks[task_id]
|
||||||
|
|
||||||
@app.get("/api/history")
|
@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)
|
tracks = get_recent_tracks(limit)
|
||||||
total = get_tracks_count()
|
total = get_tracks_count()
|
||||||
return {"total": total, "tracks": tracks}
|
return {"total": total, "tracks": tracks}
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def healthcheck():
|
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")
|
@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 status of Smart Wave buffer."""
|
||||||
return wave_rotator.get_status()
|
return wave_rotator.get_status()
|
||||||
|
|
||||||
@app.post("/api/wave/generate")
|
@app.post("/api/wave/generate")
|
||||||
async def generate_wave():
|
async def generate_wave(req: Optional[WaveGenerateRequest] = None, user: str = Depends(require_auth)):
|
||||||
"""Discover candidates and fill Smart Wave buffer up to capacity."""
|
"""Discover candidates and fill Smart Wave buffer up to capacity using LLM reranker."""
|
||||||
result = await wave_rotator.fill_buffer()
|
mood = req.mood if req else "auto"
|
||||||
|
result = await wave_rotator.fill_buffer(mood=mood)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@app.post("/api/wave/rotate")
|
@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."""
|
"""Promote starred tracks and purge expired tracks in Smart Wave buffer."""
|
||||||
promoted = wave_rotator.promote_starred_tracks()
|
promoted = wave_rotator.promote_starred_tracks()
|
||||||
purged = wave_rotator.purge_expired_tracks()
|
purged = wave_rotator.purge_expired_tracks()
|
||||||
|
|||||||
+31
-3
@@ -307,17 +307,31 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const waveLoadingText = document.getElementById("waveLoadingText");
|
const waveLoadingText = document.getElementById("waveLoadingText");
|
||||||
const waveGenerateBtn = document.getElementById("waveGenerateBtn");
|
const waveGenerateBtn = document.getElementById("waveGenerateBtn");
|
||||||
const waveRotateBtn = document.getElementById("waveRotateBtn");
|
const waveRotateBtn = document.getElementById("waveRotateBtn");
|
||||||
|
const waveMoodSelect = document.getElementById("waveMoodSelect");
|
||||||
|
const waveContextLabel = document.getElementById("waveContextLabel");
|
||||||
|
|
||||||
fetchWaveStatus();
|
fetchWaveStatus();
|
||||||
|
|
||||||
if (waveGenerateBtn) {
|
if (waveGenerateBtn) {
|
||||||
waveGenerateBtn.addEventListener("click", async () => {
|
waveGenerateBtn.addEventListener("click", async () => {
|
||||||
waveGenerateBtn.disabled = true;
|
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 {
|
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();
|
const data = await res.json();
|
||||||
alert(`Волна обновлена! Добавлено: ${data.added_count || 0} треков.`);
|
alert(`🌊 Волна обновлена под настроение [${data.mood_label || mood}]!\nДобавлено новых треков: ${data.added_count || 0}.`);
|
||||||
await fetchWaveStatus();
|
await fetchWaveStatus();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Ошибка генерации волны: " + err);
|
alert("Ошибка генерации волны: " + err);
|
||||||
@@ -333,6 +347,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
waveRotateBtn.disabled = true;
|
waveRotateBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch("api/wave/rotate", { method: "POST" });
|
const res = await fetch("api/wave/rotate", { method: "POST" });
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const promMsg = (data.promoted_count > 0) ? `⭐ Перенесено в библиотеку: ${data.promoted_count}` : "Нет лайкнутых для переноса";
|
const promMsg = (data.promoted_count > 0) ? `⭐ Перенесено в библиотеку: ${data.promoted_count}` : "Нет лайкнутых для переноса";
|
||||||
const purMsg = (data.purged_count > 0) ? `🗑️ Удалено устаревших: ${data.purged_count}` : "Нет устаревших";
|
const purMsg = (data.purged_count > 0) ? `🗑️ Удалено устаревших: ${data.purged_count}` : "Нет устаревших";
|
||||||
@@ -349,6 +367,10 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
async function fetchWaveStatus() {
|
async function fetchWaveStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("api/wave/status");
|
const res = await fetch("api/wave/status");
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
renderWaveStatus(data);
|
renderWaveStatus(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -360,6 +382,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (waveCountLabel) waveCountLabel.textContent = `${data.count || 0} / ${data.capacity || 25} треков`;
|
if (waveCountLabel) waveCountLabel.textContent = `${data.count || 0} / ${data.capacity || 25} треков`;
|
||||||
if (waveSizeLabel) waveSizeLabel.textContent = `${data.total_size_mb || 0} МБ`;
|
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
|
// Anchors
|
||||||
if (waveAnchorsContainer && waveAnchorsList) {
|
if (waveAnchorsContainer && waveAnchorsList) {
|
||||||
const anchors = data.anchor_artists || [];
|
const anchors = data.anchor_artists || [];
|
||||||
|
|||||||
@@ -731,3 +731,83 @@ input[type="text"]:focus {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 0.3rem;
|
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,6 +22,10 @@
|
|||||||
<div class="status-badges">
|
<div class="status-badges">
|
||||||
<span class="badge badge-success" id="yandexStatus">Яндекс Музыка: Активна</span>
|
<span class="badge badge-success" id="yandexStatus">Яндекс Музыка: Активна</span>
|
||||||
<span class="badge badge-info" id="tracksCount">Загружено: 0</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -74,9 +78,9 @@
|
|||||||
<section class="card wave-card">
|
<section class="card wave-card">
|
||||||
<div class="wave-header">
|
<div class="wave-header">
|
||||||
<div class="wave-header-title">
|
<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">
|
<p class="wave-subtitle">
|
||||||
Персональный поток рекомендаций на основе ваших звёздочек и скробблов. Автоматическая ротация буфера в Navidrome.
|
Персональный контекстный поток рекомендаций. Нейросетевой реранкинг по времени суток и настроению с авто-ротацией в Navidrome.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="wave-controls">
|
<div class="wave-controls">
|
||||||
@@ -85,6 +89,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-stats-row">
|
||||||
<div class="wave-stat">
|
<div class="wave-stat">
|
||||||
<span class="wave-stat-label">Буфер ротации:</span>
|
<span class="wave-stat-label">Буфер ротации:</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -8,6 +8,7 @@ from app.wave.recommender import WaveRecommender
|
|||||||
from app.wave.playlist import ensure_smart_playlist
|
from app.wave.playlist import ensure_smart_playlist
|
||||||
from app.core.tagger import get_track_dest_path
|
from app.core.tagger import get_track_dest_path
|
||||||
from app.core.lyrics import fetch_and_save_lyrics
|
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.yandex import YandexMusicProvider
|
||||||
from app.providers.universal import UniversalProvider
|
from app.providers.universal import UniversalProvider
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class WaveRotator:
|
|||||||
self.yandex_provider = YandexMusicProvider()
|
self.yandex_provider = YandexMusicProvider()
|
||||||
self.universal_provider = UniversalProvider()
|
self.universal_provider = UniversalProvider()
|
||||||
self.recommender = WaveRecommender(self.yandex_provider)
|
self.recommender = WaveRecommender(self.yandex_provider)
|
||||||
|
self.reranker = WaveLlmReranker()
|
||||||
|
|
||||||
def get_current_buffer(self) -> List[Dict[str, Any]]:
|
def get_current_buffer(self) -> List[Dict[str, Any]]:
|
||||||
"""List all audio files currently in the Wave rotation buffer."""
|
"""List all audio files currently in the Wave rotation buffer."""
|
||||||
@@ -134,8 +136,8 @@ class WaveRotator:
|
|||||||
|
|
||||||
return purged
|
return purged
|
||||||
|
|
||||||
async def fill_buffer(self) -> Dict[str, Any]:
|
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."""
|
"""Run rotation (promotion + purge) and download candidates up to target capacity using LLM reranker."""
|
||||||
promoted = self.promote_starred_tracks()
|
promoted = self.promote_starred_tracks()
|
||||||
purged = self.purge_expired_tracks()
|
purged = self.purge_expired_tracks()
|
||||||
|
|
||||||
@@ -143,22 +145,50 @@ class WaveRotator:
|
|||||||
current_count = len(current_items)
|
current_count = len(current_items)
|
||||||
needed = max(0, self.target_capacity - current_count)
|
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]] = []
|
added: List[Dict[str, str]] = []
|
||||||
|
context = self.reranker.resolve_mood_context(mood)
|
||||||
|
|
||||||
if needed > 0:
|
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()
|
profile = self.collector.get_anchor_profile()
|
||||||
existing_keys = self.collector.get_existing_track_keys()
|
existing_keys = self.collector.get_existing_track_keys()
|
||||||
wave_keys = self.get_wave_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,
|
anchor_profile=profile,
|
||||||
existing_keys=existing_keys,
|
existing_keys=existing_keys,
|
||||||
wave_keys=wave_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
|
target_count=needed
|
||||||
)
|
)
|
||||||
|
|
||||||
for cand in candidates:
|
for cand in selected_candidates:
|
||||||
filename = f"{cand.artist} - {cand.title}.mp3"
|
filename = f"{cand.artist} - {cand.title}.mp3"
|
||||||
# Clean unsafe filesystem chars
|
# Clean unsafe filesystem chars
|
||||||
filename = "".join(c for c in filename if c not in '<>:"/\\|?*')
|
filename = "".join(c for c in filename if c not in '<>:"/\\|?*')
|
||||||
@@ -187,6 +217,8 @@ class WaveRotator:
|
|||||||
return {
|
return {
|
||||||
"target_capacity": self.target_capacity,
|
"target_capacity": self.target_capacity,
|
||||||
"total_in_buffer": len(self.get_current_buffer()),
|
"total_in_buffer": len(self.get_current_buffer()),
|
||||||
|
"mood": mood,
|
||||||
|
"mood_label": context["label"],
|
||||||
"added_count": len(added),
|
"added_count": len(added),
|
||||||
"added_tracks": added,
|
"added_tracks": added,
|
||||||
"promoted_count": len(promoted),
|
"promoted_count": len(promoted),
|
||||||
@@ -201,6 +233,7 @@ class WaveRotator:
|
|||||||
total_size_mb = round(sum(t["size_mb"] for t in buffer_tracks), 2)
|
total_size_mb = round(sum(t["size_mb"] for t in buffer_tracks), 2)
|
||||||
profile = self.collector.get_anchor_profile()
|
profile = self.collector.get_anchor_profile()
|
||||||
anchor_artists = [a["artist"] for a in profile.get("top_artists", [])[:6]]
|
anchor_artists = [a["artist"] for a in profile.get("top_artists", [])[:6]]
|
||||||
|
context = self.reranker.resolve_mood_context("auto")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"capacity": self.target_capacity,
|
"capacity": self.target_capacity,
|
||||||
@@ -208,7 +241,9 @@ class WaveRotator:
|
|||||||
"total_size_mb": total_size_mb,
|
"total_size_mb": total_size_mb,
|
||||||
"expire_days": self.expire_days,
|
"expire_days": self.expire_days,
|
||||||
"anchor_artists": anchor_artists,
|
"anchor_artists": anchor_artists,
|
||||||
"tracks": buffer_tracks[:15]
|
"current_context": context,
|
||||||
|
"llm_model": settings.OLLAMA_MODEL,
|
||||||
|
"tracks": buffer_tracks[:25]
|
||||||
}
|
}
|
||||||
|
|
||||||
wave_rotator = WaveRotator()
|
wave_rotator = WaveRotator()
|
||||||
+1
-2
@@ -3,8 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: navidrome-music-sync
|
container_name: navidrome-music-sync
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
network_mode: host
|
||||||
- "127.0.0.1:8005:8005"
|
|
||||||
volumes:
|
volumes:
|
||||||
- /home/ai-projects/services/music.qpid.pro/music:/music
|
- /home/ai-projects/services/music.qpid.pro/music:/music
|
||||||
- /home/ai-projects/services/music.qpid.pro/data:/navidrome-data:ro
|
- /home/ai-projects/services/music.qpid.pro/data:/navidrome-data:ro
|
||||||
|
|||||||
Reference in new issue
Block a user