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

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

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
import json
import re
import time
from datetime import datetime
import urllib.request
import urllib.error
from typing import List, Dict, Any, Optional
from app.config import settings
from app.providers.base import TrackMetadata
class WaveLlmReranker:
def __init__(self, ollama_url: Optional[str] = None, model: Optional[str] = None):
self.ollama_url = (ollama_url or settings.OLLAMA_URL).rstrip("/")
self.model = model or settings.OLLAMA_MODEL
def get_time_of_day_context(self) -> Dict[str, str]:
"""Detect current time of day and musical context."""
hour = datetime.now().hour
if 5 <= hour < 12:
return {
"period": "утро",
"label": "🌅 Утренний чилл",
"desc": "мягкое плавное пробуждение, мелодичный чилл, акустическое или лёгкое звучание"
}
elif 12 <= hour < 18:
return {
"period": "день",
"label": "☀️ Дневной фокус",
"desc": "бодрый дневной ритм, концентрация, динамичный и сбалансированный флоу"
}
elif 18 <= hour < 23:
return {
"period": "вечер",
"label": "🌆 Вечерний вайб",
"desc": "атмосферный глубокий звук, эмоциональные треки, плотный бас и хип-хоп/рок"
}
else:
return {
"period": "ночь",
"label": "🌙 Ночной андеграунд",
"desc": "ночной драйв, меланхолия, глубокий бас, lo-fi и атмосферный андеграунд"
}
def resolve_mood_context(self, mood: str) -> Dict[str, str]:
"""Map user-selected mood string to descriptive context."""
mood_lower = (mood or "auto").lower()
if mood_lower == "energy":
return {
"period": "драйв",
"label": "⚡ Энергия & Спорт",
"desc": "взрывной ритм, максимальная динамика, бодрящий кач и быстрый темп"
}
elif mood_lower == "chill":
return {
"period": "релакс",
"label": "☕ Спокойствие & Чилл",
"desc": "расслабляющая музыка, мягкий ненавязчивый ритм, плавные переходы"
}
elif mood_lower == "dark":
return {
"period": "андеграунд",
"label": "🏴 Мрачный вайб",
"desc": "мрачный звук, глубокий саб-бас, андеграундный рэп и сырой плотный саунд"
}
# Default: auto by time of day
return self.get_time_of_day_context()
def rerank_candidates(
self,
candidates: List[TrackMetadata],
anchor_profile: Dict[str, Any],
mood: str = "auto",
target_count: int = 25
) -> List[TrackMetadata]:
"""
Rerank and select candidates using Ollama qwen2.5 model.
Falls back smoothly to heuristic ranking if Ollama is unreachable or times out.
"""
if not candidates:
return []
if len(candidates) <= target_count:
return candidates
context = self.resolve_mood_context(mood)
# Build compact candidate list representation
# Format: 1. Artist - Title
candidate_lines = []
for idx, c in enumerate(candidates, 1):
candidate_lines.append(f"{idx}. {c.artist} - {c.title}")
candidates_text = "\n".join(candidate_lines)
# Extract anchor artist names for context
top_artists = [a.get("artist") for a in anchor_profile.get("top_artists", [])[:5] if a.get("artist")]
artists_str = ", ".join(top_artists) if top_artists else "Разнообразный русский и мировой рэп/рок"
prompt = (
f"Ты — опытный музыкальный куратор радио.\n"
f"Контекст: {context['label']} ({context['desc']}).\n"
f"Любимые артисты пользователя: {artists_str}.\n\n"
f"Список кандидатов:\n{candidates_text}\n\n"
f"Задача: выбери ровно {target_count} номеров треков из списка, которые лучше всего подходят под этот вайб "
f"и обеспечат гармоничную последовательность прослушивания.\n"
f"Ответь ИСКЛЮЧИТЕЛЬНО в формате JSON списка номеров, например: [1, 5, 8, 12, ...]. Без лишнего текста."
)
print(f"[WaveLLM] Sourcing {target_count} tracks via {self.model} for mood '{context['label']}'...")
try:
req_data = json.dumps({
"model": self.model,
"prompt": prompt,
"stream": False,
"keep_alive": 0,
"options": {
"temperature": 0.25,
"num_predict": 150
}
}).encode("utf-8")
req = urllib.request.Request(
f"{self.ollama_url}/api/generate",
data=req_data,
headers={"Content-Type": "application/json"}
)
# 45 second timeout for local CPU inference
with urllib.request.urlopen(req, timeout=45) as resp:
resp_json = json.loads(resp.read().decode("utf-8"))
raw_response = resp_json.get("response", "").strip()
print(f"[WaveLLM] Model output: {raw_response[:100]}...")
# Extract JSON list of indices from response
match = re.search(r"\[([0-9,\s]+)\]", raw_response)
if match:
selected_indices = [int(x.strip()) for x in match.group(1).split(",") if x.strip().isdigit()]
# Filter valid 1-based indices
valid_tracks = []
seen_indices = set()
for idx in selected_indices:
if 1 <= idx <= len(candidates) and idx not in seen_indices:
seen_indices.add(idx)
valid_tracks.append(candidates[idx - 1])
if len(valid_tracks) >= min(5, target_count):
# If model returned fewer than target_count, fill remaining from unselected
if len(valid_tracks) < target_count:
for idx, c in enumerate(candidates, 1):
if idx not in seen_indices:
valid_tracks.append(c)
if len(valid_tracks) >= target_count:
break
print(f"[WaveLLM] Successfully reranked {len(valid_tracks)} tracks using {self.model}")
return valid_tracks[:target_count]
except Exception as e:
print(f"[WaveLLM] Notice: Ollama reranker fallback ({e}). Applying heuristic selection.")
# Deadair Fallback Principle: Heuristic selection
# Interleave tracks from diverse artists
artist_buckets: Dict[str, List[TrackMetadata]] = {}
for c in candidates:
artist_buckets.setdefault(c.artist, []).append(c)
heuristic_tracks: List[TrackMetadata] = []
while len(heuristic_tracks) < target_count and any(artist_buckets.values()):
for artist, tracks in list(artist_buckets.items()):
if tracks and len(heuristic_tracks) < target_count:
heuristic_tracks.append(tracks.pop(0))
return heuristic_tracks[:target_count]
+41 -6
View File
@@ -8,6 +8,7 @@ from app.wave.recommender import WaveRecommender
from app.wave.playlist import ensure_smart_playlist
from app.core.tagger import get_track_dest_path
from app.core.lyrics import fetch_and_save_lyrics
from app.wave.llm_reranker import WaveLlmReranker
from app.providers.yandex import YandexMusicProvider
from app.providers.universal import UniversalProvider
@@ -28,6 +29,7 @@ class WaveRotator:
self.yandex_provider = YandexMusicProvider()
self.universal_provider = UniversalProvider()
self.recommender = WaveRecommender(self.yandex_provider)
self.reranker = WaveLlmReranker()
def get_current_buffer(self) -> List[Dict[str, Any]]:
"""List all audio files currently in the Wave rotation buffer."""
@@ -134,8 +136,8 @@ class WaveRotator:
return purged
async def fill_buffer(self) -> Dict[str, Any]:
"""Run rotation (promotion + purge) and download candidates up to target capacity."""
async def fill_buffer(self, mood: str = "auto", refresh_count: int = 5) -> Dict[str, Any]:
"""Run rotation (promotion + purge) and download candidates up to target capacity using LLM reranker."""
promoted = self.promote_starred_tracks()
purged = self.purge_expired_tracks()
@@ -143,22 +145,50 @@ class WaveRotator:
current_count = len(current_items)
needed = max(0, self.target_capacity - current_count)
# If buffer is full and user requests generation, rotate out oldest unstarred tracks to inject fresh recommendations
if needed == 0 and refresh_count > 0:
sorted_by_age = sorted(current_items, key=lambda x: x["mtime"])
to_remove = sorted_by_age[:refresh_count]
for item in to_remove:
try:
os.remove(item["path"])
lrc = os.path.splitext(item["path"])[0] + ".lrc"
if os.path.exists(lrc):
os.remove(lrc)
print(f"[WaveRotator] Rotating out oldest wave track: {item['filename']}")
except Exception as e:
print(f"[WaveRotator] Failed to remove {item['filename']}: {e}")
current_items = self.get_current_buffer()
current_count = len(current_items)
needed = max(0, self.target_capacity - current_count)
added: List[Dict[str, str]] = []
context = self.reranker.resolve_mood_context(mood)
if needed > 0:
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing {needed} new tracks...")
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing candidates for {needed} slots (mood: {context['label']})...")
profile = self.collector.get_anchor_profile()
existing_keys = self.collector.get_existing_track_keys()
wave_keys = self.get_wave_track_keys()
candidates = self.recommender.get_candidate_tracks(
# Source a larger candidate pool for LLM contextual reranking
candidate_pool_size = max(needed * 2, settings.WAVE_CANDIDATE_POOL)
raw_candidates = self.recommender.get_candidate_tracks(
anchor_profile=profile,
existing_keys=existing_keys,
wave_keys=wave_keys,
target_count=candidate_pool_size
)
# Contextual LLM Reranker (Qwen 2.5 / deadair fallback)
selected_candidates = self.reranker.rerank_candidates(
candidates=raw_candidates,
anchor_profile=profile,
mood=mood,
target_count=needed
)
for cand in candidates:
for cand in selected_candidates:
filename = f"{cand.artist} - {cand.title}.mp3"
# Clean unsafe filesystem chars
filename = "".join(c for c in filename if c not in '<>:"/\\|?*')
@@ -187,6 +217,8 @@ class WaveRotator:
return {
"target_capacity": self.target_capacity,
"total_in_buffer": len(self.get_current_buffer()),
"mood": mood,
"mood_label": context["label"],
"added_count": len(added),
"added_tracks": added,
"promoted_count": len(promoted),
@@ -201,6 +233,7 @@ class WaveRotator:
total_size_mb = round(sum(t["size_mb"] for t in buffer_tracks), 2)
profile = self.collector.get_anchor_profile()
anchor_artists = [a["artist"] for a in profile.get("top_artists", [])[:6]]
context = self.reranker.resolve_mood_context("auto")
return {
"capacity": self.target_capacity,
@@ -208,7 +241,9 @@ class WaveRotator:
"total_size_mb": total_size_mb,
"expire_days": self.expire_days,
"anchor_artists": anchor_artists,
"tracks": buffer_tracks[:15]
"current_context": context,
"llm_model": settings.OLLAMA_MODEL,
"tracks": buffer_tracks[:25]
}
wave_rotator = WaveRotator()