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 @@
@@ -74,9 +78,9 @@- Персональный поток рекомендаций на основе ваших звёздочек и скробблов. Автоматическая ротация буфера в Navidrome. + Персональный контекстный поток рекомендаций. Нейросетевой реранкинг по времени суток и настроению с авто-ротацией в Navidrome.
Авторизация через учётную запись Navidrome
+