feat(wave): implement Phase 1 Smart Wave recommendations and buffer rotation

This commit is contained in:
Antigravity committed 2026-10-12 14:43:29 +03:00
1 parent aa6c605d14
commit 9fbe372fab
10 files changed
+879

No files matched your search

+1
View File
@@ -0,0 +1 @@
# app/wave/__init__.py
+178
View File
@@ -0,0 +1,178 @@
import os
import sqlite3
import re
from typing import Dict, List, Set, Any, Optional
def _normalize(s: Optional[str]) -> str:
if not s:
return ""
s = s.lower()
s = re.sub(r"[^\w\s]", "", s)
return " ".join(s.split())
class WaveCollector:
def __init__(self, db_path: str = "/navidrome-data/navidrome.db"):
self.db_path = db_path
# Fallback paths if running outside docker
if not os.path.exists(self.db_path):
candidates = [
"/home/ai-projects/services/music.qpid.pro/data/navidrome.db",
"../music.qpid.pro/data/navidrome.db"
]
for c in candidates:
if os.path.exists(c):
self.db_path = c
break
def _get_connection(self) -> Optional[sqlite3.Connection]:
if not os.path.exists(self.db_path):
return None
try:
return sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
except Exception as e:
print(f"[WaveCollector] Error connecting to Navidrome DB: {e}")
return None
def get_anchor_profile(self) -> Dict[str, Any]:
"""Collect user preference anchors: starred tracks, played tracks, top artists."""
profile: Dict[str, Any] = {
"starred": [],
"top_played": [],
"top_artists": [],
"recent_added": []
}
conn = self._get_connection()
if not conn:
return profile
try:
c = conn.cursor()
# 1. Starred tracks (high signal)
c.execute("""
SELECT mf.id, mf.artist, mf.title, mf.album, mf.genre, a.starred_at
FROM annotation a
JOIN media_file mf ON a.item_id = mf.id
WHERE a.starred = 1
ORDER BY a.starred_at DESC
LIMIT 50
""")
for row in c.fetchall():
profile["starred"].append({
"id": row[0],
"artist": row[1],
"title": row[2],
"album": row[3],
"genre": row[4],
"starred_at": row[5]
})
# 2. Top played tracks (medium-high signal)
c.execute("""
SELECT mf.id, mf.artist, mf.title, a.play_count, a.play_date
FROM annotation a
JOIN media_file mf ON a.item_id = mf.id
WHERE a.play_count > 0
ORDER BY a.play_count DESC, a.play_date DESC
LIMIT 30
""")
for row in c.fetchall():
profile["top_played"].append({
"id": row[0],
"artist": row[1],
"title": row[2],
"play_count": row[3],
"play_date": row[4]
})
# 3. Top artists in library (general library profile)
c.execute("""
SELECT artist, count(*) as cnt
FROM media_file
WHERE artist IS NOT NULL AND artist != ''
GROUP BY artist
ORDER BY cnt DESC
LIMIT 25
""")
for row in c.fetchall():
profile["top_artists"].append({
"artist": row[0],
"count": row[1]
})
# 4. Recently added tracks
c.execute("""
SELECT id, artist, title, album, created_at
FROM media_file
ORDER BY created_at DESC
LIMIT 20
""")
for row in c.fetchall():
profile["recent_added"].append({
"id": row[0],
"artist": row[1],
"title": row[2],
"album": row[3],
"created_at": row[4]
})
except Exception as e:
print(f"[WaveCollector] Profile extraction error: {e}")
finally:
conn.close()
return profile
def get_existing_track_keys(self) -> Set[str]:
"""Returns set of normalized 'artist|title' of all songs already in Navidrome library."""
keys: Set[str] = set()
conn = self._get_connection()
if not conn:
return keys
try:
c = conn.cursor()
c.execute("SELECT artist, title FROM media_file")
for r in c.fetchall():
art = _normalize(r[0])
tit = _normalize(r[1])
if art and tit:
keys.add(f"{art}|{tit}")
except Exception as e:
print(f"[WaveCollector] Error reading existing track keys: {e}")
finally:
conn.close()
return keys
def get_starred_wave_tracks(self, wave_subpath: str = "AI Recommendations/Моя Волна") -> List[Dict[str, Any]]:
"""Find tracks in Wave folder that the user has starred (ready for permanent promotion)."""
starred_wave: List[Dict[str, Any]] = []
conn = self._get_connection()
if not conn:
return starred_wave
try:
c = conn.cursor()
pattern = f"{wave_subpath}/%"
c.execute("""
SELECT mf.id, mf.path, mf.artist, mf.album, mf.title, a.starred_at
FROM annotation a
JOIN media_file mf ON a.item_id = mf.id
WHERE a.starred = 1 AND mf.path LIKE ?
""", (pattern,))
for r in c.fetchall():
starred_wave.append({
"id": r[0],
"path": r[1],
"artist": r[2],
"album": r[3],
"title": r[4],
"starred_at": r[5]
})
except Exception as e:
print(f"[WaveCollector] Error finding starred wave tracks: {e}")
finally:
conn.close()
return starred_wave
+42
View File
@@ -0,0 +1,42 @@
import os
import json
from app.config import settings
def ensure_smart_playlist(playlist_name: str = "Моя Волна") -> str:
"""Ensure Navidrome .nsp Smart Playlist exists in library."""
playlists_dir = os.path.join(settings.MUSIC_DIR, "playlists")
os.makedirs(playlists_dir, exist_ok=True)
nsp_path = os.path.join(playlists_dir, f"{playlist_name}.nsp")
# Path inside Navidrome is relative to /music
nsp_content = {
"all": [
{
"path": {
"contains": "AI Recommendations/Моя Волна"
}
}
],
"sort": "random",
"limit": 100
}
with open(nsp_path, "w", encoding="utf-8") as f:
json.dump(nsp_content, f, ensure_ascii=False, indent=2)
# Also generate M3U8 playlist at music root for universal Subsonic auto-import
wave_dir = os.path.join(settings.MUSIC_DIR, "AI Recommendations", "Моя Волна")
m3u_path = os.path.join(settings.MUSIC_DIR, f"{playlist_name}.m3u8")
lines = ["#EXTM3U\n"]
if os.path.exists(wave_dir):
for f in sorted(os.listdir(wave_dir)):
if f.lower().endswith(".mp3"):
lines.append(f"#EXTINF:-1,{os.path.splitext(f)[0]}\n")
lines.append(f"AI Recommendations/Моя Волна/{f}\n")
with open(m3u_path, "w", encoding="utf-8") as f:
f.writelines(lines)
return nsp_path
+132
View File
@@ -0,0 +1,132 @@
import random
import re
from typing import List, Dict, Set, Any, Optional
from app.providers.yandex import YandexMusicProvider
from app.providers.base import TrackMetadata
def _normalize(s: Optional[str]) -> str:
if not s:
return ""
s = s.lower()
s = re.sub(r"[^\w\s]", "", s)
return " ".join(s.split())
class WaveRecommender:
def __init__(self, yandex_provider: Optional[YandexMusicProvider] = None):
self.yandex_provider = yandex_provider or YandexMusicProvider()
def get_candidate_tracks(
self,
anchor_profile: Dict[str, Any],
existing_keys: Set[str],
wave_keys: Set[str],
target_count: int = 20
) -> List[TrackMetadata]:
"""Discover candidate tracks based on user anchors not yet in the library."""
candidates: List[TrackMetadata] = []
seen_keys: Set[str] = set()
try:
client = self.yandex_provider.get_client()
except Exception as e:
print(f"[WaveRecommender] Yandex client unavailable: {e}")
return candidates
# 1. Select anchor artists from profile
anchor_artists: List[str] = []
# Pull from starred
for s in anchor_profile.get("starred", []):
if s.get("artist") and s["artist"] not in anchor_artists:
anchor_artists.append(s["artist"])
# Pull from top played
for p in anchor_profile.get("top_played", []):
if p.get("artist") and p["artist"] not in anchor_artists:
anchor_artists.append(p["artist"])
# Pull from top library artists
for a in anchor_profile.get("top_artists", []):
if a.get("artist") and a["artist"] not in anchor_artists:
anchor_artists.append(a["artist"])
# Fallback to recent if empty
if not anchor_artists:
for r in anchor_profile.get("recent_added", []):
if r.get("artist") and r["artist"] not in anchor_artists:
anchor_artists.append(r["artist"])
if not anchor_artists:
print("[WaveRecommender] No anchors found in user profile.")
return candidates
# Randomly sample 3-5 anchors for diversity in this run
sample_size = min(5, len(anchor_artists))
selected_anchors = random.sample(anchor_artists, sample_size)
print(f"[WaveRecommender] Sourcing candidates using anchors: {selected_anchors}")
for anchor_name in selected_anchors:
if len(candidates) >= target_count:
break
try:
# Search for anchor artist to get artist ID
search_res = client.search(anchor_name, type_="artist")
if not search_res or not search_res.artists or not search_res.artists.results:
continue
artist_obj = search_res.artists.results[0]
# Sourcing A: Similar artists
sim_res = client.artists_similar(artist_obj.id)
similar_artists = sim_res.similar_artists[:4] if sim_res and sim_res.similar_artists else []
for sim_art in similar_artists:
if len(candidates) >= target_count:
break
try:
tracks_res = client.artists_tracks(sim_art.id, page=0, page_size=6)
if not tracks_res or not tracks_res.tracks:
continue
for t in tracks_res.tracks:
if not t or not t.available:
continue
art_name = ", ".join(a.name for a in t.artists) if t.artists else sim_art.name
key = f"{_normalize(art_name)}|{_normalize(t.title)}"
# Filter out existing library or wave tracks
if key in existing_keys or key in wave_keys or key in seen_keys:
continue
meta = self.yandex_provider._convert_track(t)
if meta:
seen_keys.add(key)
candidates.append(meta)
if len(candidates) >= target_count:
break
except Exception as e:
print(f"[WaveRecommender] Error fetching tracks for {sim_art.name}: {e}")
# Sourcing B: Also get popular tracks from the anchor artist's own catalog that user doesn't have yet
if len(candidates) < target_count:
own_tracks = client.artists_tracks(artist_obj.id, page=0, page_size=6)
if own_tracks and own_tracks.tracks:
for t in own_tracks.tracks:
if not t or not t.available:
continue
art_name = ", ".join(a.name for a in t.artists) if t.artists else artist_obj.name
key = f"{_normalize(art_name)}|{_normalize(t.title)}"
if key not in existing_keys and key not in wave_keys and key not in seen_keys:
meta = self.yandex_provider._convert_track(t)
if meta:
seen_keys.add(key)
candidates.append(meta)
if len(candidates) >= target_count:
break
except Exception as e:
print(f"[WaveRecommender] Error processing anchor {anchor_name}: {e}")
# Randomize candidates order so the playlist feels fresh
random.shuffle(candidates)
return candidates[:target_count]
+214
View File
@@ -0,0 +1,214 @@
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.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)
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) -> Dict[str, Any]:
"""Run rotation (promotion + purge) and download candidates up to target capacity."""
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)
added: List[Dict[str, str]] = []
if needed > 0:
print(f"[WaveRotator] Wave buffer at {current_count}/{self.target_capacity}. Sourcing {needed} new tracks...")
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(
anchor_profile=profile,
existing_keys=existing_keys,
wave_keys=wave_keys,
target_count=needed
)
for cand in 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()),
"added_count": len(added),
"added_tracks": added,
"promoted_count": len(promoted),
"promoted_tracks": promoted,
"purged_count": len(purged),
"purged_tracks": purged
}
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]]
return {
"capacity": self.target_capacity,
"count": len(buffer_tracks),
"total_size_mb": total_size_mb,
"expire_days": self.expire_days,
"anchor_artists": anchor_artists,
"tracks": buffer_tracks[:15]
}
wave_rotator = WaveRotator()