feat(wave): implement Phase 1 Smart Wave recommendations and buffer rotation
This commit is contained in:
1 parent
aa6c605d14
commit
9fbe372fab
10 files changed
+879
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user