Files
navidrome-music-sync/app/wave/rotator.py
T
Antigravity e5afeb06cc feat: Phase 3 AI Radio (DJ Host & interleaved playlist) and Like/Dislike/Delete actions
- Add Like button: promotes wave track to permanent library and refills buffer
- Add Dislike button: blacklists track in SQLite disliked_tracks, removes audio, refills buffer
- Add Delete button in history table for library file removal
- Add AI Radio module: wttr.in weather, Qwen 2.5 host monologues, speech synthesis, ID3 tags
- Generate interleaved 'AI Радио.m3u8' alongside clean 'Моя Волна.m3u8'
- Update Web UI with Radio DJ card, voice selection, and track actions
2026-10-12 16:34:46 +03:00

344 lines
14 KiB
Python

import os
import time
import shutil
from typing import List, Dict, Any, Set, Optional
from app.config import settings
from app.wave.collector import WaveCollector, _normalize
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
class WaveRotator:
def __init__(
self,
target_capacity: int = 25,
expire_days: int = 7,
subpath: str = "AI Recommendations/Моя Волна"
):
self.target_capacity = target_capacity
self.expire_days = expire_days
self.subpath = subpath
self.wave_dir = os.path.join(settings.MUSIC_DIR, subpath)
os.makedirs(self.wave_dir, exist_ok=True)
self.collector = WaveCollector()
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."""
items: List[Dict[str, Any]] = []
if not os.path.exists(self.wave_dir):
return items
now = time.time()
for f in os.listdir(self.wave_dir):
if not f.lower().endswith(".mp3"):
continue
fpath = os.path.join(self.wave_dir, f)
stat = os.stat(fpath)
size_mb = round(stat.st_size / (1024 * 1024), 2)
age_days = round((now - stat.st_mtime) / 86400, 1)
# Derive artist and title from filename
name_no_ext = os.path.splitext(f)[0]
artist = "Unknown"
title = name_no_ext
if " - " in name_no_ext:
parts = name_no_ext.split(" - ", 1)
artist = parts[0].strip()
title = parts[1].strip()
items.append({
"filename": f,
"path": fpath,
"artist": artist,
"title": title,
"size_mb": size_mb,
"age_days": age_days,
"mtime": stat.st_mtime
})
items.sort(key=lambda x: x["mtime"], reverse=True)
return items
def get_wave_track_keys(self) -> Set[str]:
"""Normalized keys ('artist|title') of songs currently in Wave."""
keys = set()
for item in self.get_current_buffer():
art = _normalize(item["artist"])
tit = _normalize(item["title"])
if art and tit:
keys.add(f"{art}|{tit}")
return keys
def promote_starred_tracks(self) -> List[Dict[str, str]]:
"""Promote Wave tracks that user has starred in Navidrome to permanent library."""
promoted: List[Dict[str, str]] = []
starred_in_wave = self.collector.get_starred_wave_tracks(self.subpath)
for item in starred_in_wave:
src_path = os.path.join(settings.MUSIC_DIR, item["path"])
if not os.path.exists(src_path):
continue
# Target location in permanent library: /music/Artist/Album/Title.mp3
dest_path = get_track_dest_path(
settings.MUSIC_DIR,
artist=item["artist"],
album=item["album"] or "Singles",
track_num=None,
title=item["title"],
ext="mp3"
)
try:
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
shutil.move(src_path, dest_path)
# Move .lrc if exists
src_lrc = os.path.splitext(src_path)[0] + ".lrc"
if os.path.exists(src_lrc):
dest_lrc = os.path.splitext(dest_path)[0] + ".lrc"
shutil.move(src_lrc, dest_lrc)
print(f"[WaveRotator] ⭐ Promoted starred track: {item['artist']} - {item['title']} -> {dest_path}")
promoted.append({"artist": item["artist"], "title": item["title"], "dest": dest_path})
except Exception as e:
print(f"[WaveRotator] Failed to promote {item['title']}: {e}")
return promoted
def purge_expired_tracks(self) -> List[Dict[str, str]]:
"""Remove tracks older than expire_days that were not starred."""
purged: List[Dict[str, str]] = []
now = time.time()
max_age_seconds = self.expire_days * 86400
for item in self.get_current_buffer():
age_sec = now - item["mtime"]
if age_sec > max_age_seconds:
try:
os.remove(item["path"])
lrc_path = os.path.splitext(item["path"])[0] + ".lrc"
if os.path.exists(lrc_path):
os.remove(lrc_path)
print(f"[WaveRotator] 🗑️ Purged expired wave track: {item['artist']} - {item['title']} (age: {item['age_days']} days)")
purged.append({"artist": item["artist"], "title": item["title"]})
except Exception as e:
print(f"[WaveRotator] Failed to remove {item['filename']}: {e}")
return purged
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()
current_items = self.get_current_buffer()
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 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()
# 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 selected_candidates:
filename = f"{cand.artist} - {cand.title}.mp3"
# Clean unsafe filesystem chars
filename = "".join(c for c in filename if c not in '<>:"/\\|?*')
dest_path = os.path.join(self.wave_dir, filename)
try:
success = await self.yandex_provider.download_track(cand, dest_path)
if not success:
success = await self.universal_provider.download_track(cand, dest_path)
if success:
# Fetch and save .lrc lyrics
try:
fetch_and_save_lyrics(dest_path, f"{cand.artist} - {cand.title}")
except Exception as e:
print(f"[WaveRotator] Warning: lyrics fetch error for {cand.title}: {e}")
added.append({"artist": cand.artist, "title": cand.title})
print(f"[WaveRotator] 🌊 Added to Wave: {cand.artist} - {cand.title}")
except Exception as e:
print(f"[WaveRotator] Error downloading candidate {cand.title}: {e}")
# Ensure Smart Playlist exists
ensure_smart_playlist()
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),
"promoted_tracks": promoted,
"purged_count": len(purged),
"purged_tracks": purged
}
async def dislike_track(self, filename: str) -> Dict[str, Any]:
"""User dislikes a track: delete it from Wave, add to blacklisted tracks, refill buffer."""
fpath = os.path.join(self.wave_dir, os.path.basename(filename))
if not os.path.exists(fpath):
raise FileNotFoundError(f"Track not found in wave: {filename}")
name_no_ext = os.path.splitext(os.path.basename(filename))[0]
artist = "Unknown"
title = name_no_ext
if " - " in name_no_ext:
parts = name_no_ext.split(" - ", 1)
artist = parts[0].strip()
title = parts[1].strip()
from app.db import add_disliked_track
add_disliked_track(artist, title)
print(f"[WaveRotator] 👎 Disliked track: {artist} - {title}")
try:
os.remove(fpath)
lrc = os.path.splitext(fpath)[0] + ".lrc"
if os.path.exists(lrc):
os.remove(lrc)
except Exception as e:
print(f"[WaveRotator] Error removing disliked track {filename}: {e}")
# Refill buffer by 1 track
await self.fill_buffer(refresh_count=0)
ensure_smart_playlist()
return {
"status": "disliked",
"artist": artist,
"title": title,
"buffer_status": self.get_status()
}
async def like_track(self, filename: str) -> Dict[str, Any]:
"""User likes a track: promote it immediately to permanent library, refill buffer."""
fpath = os.path.join(self.wave_dir, os.path.basename(filename))
if not os.path.exists(fpath):
raise FileNotFoundError(f"Track not found in wave: {filename}")
name_no_ext = os.path.splitext(os.path.basename(filename))[0]
artist = "Unknown"
title = name_no_ext
album = "Singles"
if " - " in name_no_ext:
parts = name_no_ext.split(" - ", 1)
artist = parts[0].strip()
title = parts[1].strip()
dest_path = get_track_dest_path(
settings.MUSIC_DIR,
artist=artist,
album=album,
track_num=None,
title=title,
ext="mp3"
)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
shutil.move(fpath, dest_path)
# Move .lrc if exists
src_lrc = os.path.splitext(fpath)[0] + ".lrc"
if os.path.exists(src_lrc):
dest_lrc = os.path.splitext(dest_path)[0] + ".lrc"
shutil.move(src_lrc, dest_lrc)
# Record in downloaded_tracks
from app.db import record_downloaded_track
record_downloaded_track(
service="wave_liked",
service_id=filename,
artist=artist,
title=title,
album=album,
file_path=dest_path
)
print(f"[WaveRotator] ⭐ Liked and promoted to library: {artist} - {title} -> {dest_path}")
# Refill buffer by 1 track
await self.fill_buffer(refresh_count=0)
ensure_smart_playlist()
return {
"status": "liked",
"artist": artist,
"title": title,
"dest_path": dest_path,
"buffer_status": self.get_status()
}
def get_status(self) -> Dict[str, Any]:
"""Get summary status of Smart Wave."""
buffer_tracks = self.get_current_buffer()
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,
"count": len(buffer_tracks),
"total_size_mb": total_size_mb,
"expire_days": self.expire_days,
"anchor_artists": anchor_artists,
"current_context": context,
"llm_model": settings.OLLAMA_MODEL,
"tracks": buffer_tracks[:25]
}
wave_rotator = WaveRotator()