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 @@
+
+
+
+
+
+
+ Буфер ротации:
+ 0 / 25 треков
+
+
+ Объём на диске:
+ 0 МБ
+
+
+ Срок хранения:
+ 7 дней
+
+
+
+
+
+
Якорные исполнители вкуса:
+
+
+
+
+
+
+
+
Волна пуста. Нажмите «Обновить Волну» для генерации первого потока.
+
+
+
+
⚡ Очередь задач
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: