From fb1a5e01dd2804c43d7641da68ef207ebe4c23c3 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 12 Oct 2026 15:29:43 +0300 Subject: [PATCH] feat(wave-v2): add LLM reranking, mood modes, auto-rotation scheduler and Navidrome auth --- README.md | 13 ++- app/config.py | 14 ++++ app/core/auth.py | 110 ++++++++++++++++++++++++ app/main.py | 133 ++++++++++++++++++++++++----- app/static/app.js | 34 +++++++- app/static/style.css | 80 ++++++++++++++++++ app/templates/index.html | 24 +++++- app/templates/login.html | 140 +++++++++++++++++++++++++++++++ app/wave/llm_reranker.py | 175 +++++++++++++++++++++++++++++++++++++++ app/wave/rotator.py | 47 +++++++++-- docker-compose.yml | 3 +- 11 files changed, 736 insertions(+), 37 deletions(-) create mode 100644 app/core/auth.py create mode 100644 app/templates/login.html create mode 100644 app/wave/llm_reranker.py diff --git a/README.md b/README.md index 1d6dfd9..8e781a8 100644 --- a/README.md +++ b/README.md @@ -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}` — Прямая отдача файла в браузер. diff --git a/app/config.py b/app/config.py index 2d6e9fe..f551b9a 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..ca086dd --- /dev/null +++ b/app/core/auth.py @@ -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 diff --git a/app/main.py b/app/main.py index b47927b..400e2f6 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/static/app.js b/app/static/app.js index 5b42b9c..8cc16dc 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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 = ` 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 || []; diff --git a/app/static/style.css b/app/static/style.css index 95c867a..73a25db 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; +} + diff --git a/app/templates/index.html b/app/templates/index.html index 97e546a..76f037a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -22,6 +22,10 @@
Яндекс Музыка: Активна Загружено: 0 + {% if user %} + 👤 {{ user }} + Выйти 🚪 + {% endif %}
@@ -74,9 +78,9 @@
-

🌊 Моя Волна Smart Wave V1

+

🌊 Моя Волна Smart Wave V2 🤖 Qwen 2.5

- Персональный поток рекомендаций на основе ваших звёздочек и скробблов. Автоматическая ротация буфера в Navidrome. + Персональный контекстный поток рекомендаций. Нейросетевой реранкинг по времени суток и настроению с авто-ротацией в Navidrome.

@@ -85,6 +89,22 @@
+ +
+
+ + +
+
+ Определение контекста... +
+
+
Буфер ротации: diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..451082f --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,140 @@ + + + + + + Вход — Navidrome Music Sync + + + + + + + + diff --git a/app/wave/llm_reranker.py b/app/wave/llm_reranker.py new file mode 100644 index 0000000..2fc67d9 --- /dev/null +++ b/app/wave/llm_reranker.py @@ -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] diff --git a/app/wave/rotator.py b/app/wave/rotator.py index d151951..7277910 100644 --- a/app/wave/rotator.py +++ b/app/wave/rotator.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index 5494a94..a536eab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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