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

+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