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