diff --git a/app/main.py b/app/main.py index 7512dc5..b47927b 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,8 @@ from app.db import init_db, get_recent_tracks, get_tracks_count, get_db from app.queue import sync_manager from app.core.search import search_all from app.core.tagger import get_track_dest_path +from app.wave.rotator import wave_rotator +from app.wave.playlist import ensure_smart_playlist app = FastAPI(title="Navidrome Music Sync", version="1.0.0") @@ -26,6 +28,10 @@ class DownloadRequest(BaseModel): @app.on_event("startup") def on_startup(): init_db() + try: + ensure_smart_playlist() + except Exception as e: + print(f"Warning: Failed to ensure smart playlist: {e}") @app.get("/") def read_root(request: Request): @@ -82,6 +88,13 @@ async def direct_download(url: str = Query(...)): @app.get("/api/stream") async def stream_audio(url: str = Query(...)): """Stream audio track for in-browser playback and seeking with HTTP Range support.""" + if url.startswith("/") and os.path.exists(url) and os.path.realpath(url).startswith(os.path.realpath(settings.MUSIC_DIR)): + return FileResponse( + url, + media_type="audio/mpeg", + headers={"Accept-Ranges": "bytes", "Content-Disposition": "inline"} + ) + provider = sync_manager.get_provider(url) tracks = await provider.resolve(url) if not tracks: @@ -140,3 +153,29 @@ def get_history(limit: int = 50): @app.get("/api/health") def healthcheck(): return {"status": "ok", "yandex_configured": bool(settings.YANDEX_MUSIC_TOKEN)} + +# --- Smart Wave Endpoints --- +@app.get("/api/wave/status") +def get_wave_status(): + """Return status of Smart Wave buffer.""" + return wave_rotator.get_status() + +@app.post("/api/wave/generate") +async def generate_wave(): + """Discover candidates and fill Smart Wave buffer up to capacity.""" + result = await wave_rotator.fill_buffer() + return result + +@app.post("/api/wave/rotate") +def rotate_wave(): + """Promote starred tracks and purge expired tracks in Smart Wave buffer.""" + promoted = wave_rotator.promote_starred_tracks() + purged = wave_rotator.purge_expired_tracks() + ensure_smart_playlist() + return { + "promoted_count": len(promoted), + "promoted_tracks": promoted, + "purged_count": len(purged), + "purged_tracks": purged, + "current_status": wave_rotator.get_status() + } diff --git a/app/static/app.js b/app/static/app.js index 74f1a96..5b42b9c 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -297,4 +297,102 @@ document.addEventListener("DOMContentLoaded", () => { const d = new Date(isoStr); return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } + + // --- Smart Wave Logic --- + const waveCountLabel = document.getElementById("waveCountLabel"); + const waveSizeLabel = document.getElementById("waveSizeLabel"); + const waveAnchorsContainer = document.getElementById("waveAnchorsContainer"); + const waveAnchorsList = document.getElementById("waveAnchorsList"); + const waveTracksList = document.getElementById("waveTracksList"); + const waveLoadingText = document.getElementById("waveLoadingText"); + const waveGenerateBtn = document.getElementById("waveGenerateBtn"); + const waveRotateBtn = document.getElementById("waveRotateBtn"); + + fetchWaveStatus(); + + if (waveGenerateBtn) { + waveGenerateBtn.addEventListener("click", async () => { + waveGenerateBtn.disabled = true; + if (waveLoadingText) waveLoadingText.style.display = "inline-block"; + try { + const res = await fetch("api/wave/generate", { method: "POST" }); + const data = await res.json(); + alert(`Волна обновлена! Добавлено: ${data.added_count || 0} треков.`); + await fetchWaveStatus(); + } catch (err) { + alert("Ошибка генерации волны: " + err); + } finally { + waveGenerateBtn.disabled = false; + if (waveLoadingText) waveLoadingText.style.display = "none"; + } + }); + } + + if (waveRotateBtn) { + waveRotateBtn.addEventListener("click", async () => { + waveRotateBtn.disabled = true; + try { + const res = await fetch("api/wave/rotate", { method: "POST" }); + const data = await res.json(); + const promMsg = (data.promoted_count > 0) ? `⭐ Перенесено в библиотеку: ${data.promoted_count}` : "Нет лайкнутых для переноса"; + const purMsg = (data.purged_count > 0) ? `🗑️ Удалено устаревших: ${data.purged_count}` : "Нет устаревших"; + alert(`Ротация завершена!\n${promMsg}\n${purMsg}`); + await fetchWaveStatus(); + } catch (err) { + alert("Ошибка ротации: " + err); + } finally { + waveRotateBtn.disabled = false; + } + }); + } + + async function fetchWaveStatus() { + try { + const res = await fetch("api/wave/status"); + const data = await res.json(); + renderWaveStatus(data); + } catch (err) { + console.error("Wave status error:", err); + } + } + + function renderWaveStatus(data) { + if (waveCountLabel) waveCountLabel.textContent = `${data.count || 0} / ${data.capacity || 25} треков`; + if (waveSizeLabel) waveSizeLabel.textContent = `${data.total_size_mb || 0} МБ`; + + // Anchors + if (waveAnchorsContainer && waveAnchorsList) { + const anchors = data.anchor_artists || []; + if (anchors.length > 0) { + waveAnchorsContainer.style.display = "block"; + waveAnchorsList.innerHTML = anchors.map(a => `${escapeHtml(a)}`).join(""); + } else { + waveAnchorsContainer.style.display = "none"; + } + } + + // Tracks list + if (waveTracksList) { + const tracks = data.tracks || []; + if (tracks.length === 0) { + waveTracksList.innerHTML = `

Волна пуста. Нажмите «Обновить Волну» для генерации первого потока.

`; + } else { + waveTracksList.innerHTML = tracks.map(t => ` +
+
+ ${escapeHtml(t.title)} + + ${escapeHtml(t.artist)} • ${t.size_mb} МБ (${t.age_days} дн. назад) + +
+
+ +
+
+ `).join(""); + } + } + } }); diff --git a/app/static/style.css b/app/static/style.css index cd8f774..95c867a 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -610,3 +610,124 @@ input[type="text"]:focus { justify-content: flex-end; } } + +/* Smart Wave Styles */ +.badge-wave { + background-color: rgba(6, 182, 212, 0.2); + color: #22d3ee; + border: 1px solid rgba(6, 182, 212, 0.4); + font-size: 0.72rem; + font-weight: 700; + padding: 0.15rem 0.5rem; + border-radius: 4px; +} + +.wave-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.wave-subtitle { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.wave-controls { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + +.wave-stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + background-color: #0f172a; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.75rem 1rem; +} + +.wave-stat { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.wave-stat-label { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.wave-stat strong { + font-size: 0.95rem; + color: #f8fafc; +} + +.wave-stat strong a { + color: #22d3ee; + text-decoration: none; +} +.wave-stat strong a:hover { + text-decoration: underline; +} + +.wave-anchors-tags { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + margin-top: 0.3rem; +} + +.tag-anchor { + background-color: #1e293b; + border: 1px solid #334155; + color: #94a3b8; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.78rem; +} + +.wave-tracks-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.6rem; +} + +.wave-tracks-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 420px; + overflow-y: auto; +} + +.wave-track-item { + display: flex; + align-items: center; + justify-content: space-between; + background-color: #0f172a; + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 0.5rem 0.8rem; + gap: 0.75rem; +} + +.spinner-sm { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--accent-color); + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.8s linear infinite; + vertical-align: middle; + margin-right: 0.3rem; +} diff --git a/app/templates/index.html b/app/templates/index.html index 2b5c9f0..97e546a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -70,6 +70,59 @@ + +
+
+
+

🌊 Моя Волна Smart Wave V1

+

+ Персональный поток рекомендаций на основе ваших звёздочек и скробблов. Автоматическая ротация буфера в Navidrome. +

+
+
+ + +
+
+ +
+
+ Буфер ротации: + 0 / 25 треков +
+
+ Объём на диске: + 0 МБ +
+
+ Срок хранения: + 7 дней +
+
+ Умный плейлист: + Моя Волна.nsp ↗ +
+
+ + + + +
+
+

🎵 Треки сейчас в потоке Волны

+ +
+
+

Волна пуста. Нажмите «Обновить Волну» для генерации первого потока.

+
+
+
+

⚡ Очередь задач

diff --git a/app/wave/__init__.py b/app/wave/__init__.py new file mode 100644 index 0000000..a527067 --- /dev/null +++ b/app/wave/__init__.py @@ -0,0 +1 @@ +# app/wave/__init__.py diff --git a/app/wave/collector.py b/app/wave/collector.py new file mode 100644 index 0000000..31d9953 --- /dev/null +++ b/app/wave/collector.py @@ -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 diff --git a/app/wave/playlist.py b/app/wave/playlist.py new file mode 100644 index 0000000..e5ef915 --- /dev/null +++ b/app/wave/playlist.py @@ -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 diff --git a/app/wave/recommender.py b/app/wave/recommender.py new file mode 100644 index 0000000..1d65f11 --- /dev/null +++ b/app/wave/recommender.py @@ -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] diff --git a/app/wave/rotator.py b/app/wave/rotator.py new file mode 100644 index 0000000..d151951 --- /dev/null +++ b/app/wave/rotator.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index 3a6943e..5494a94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,7 @@ services: - "127.0.0.1:8005:8005" volumes: - /home/ai-projects/services/music.qpid.pro/music:/music + - /home/ai-projects/services/music.qpid.pro/data:/navidrome-data:ro - ./data:/app/data - ./app:/app/app env_file: