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
+39
@@ -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()
|
||||
}
|
||||
@@ -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 => `<span class="tag-anchor">${escapeHtml(a)}</span>`).join("");
|
||||
} else {
|
||||
waveAnchorsContainer.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks list
|
||||
if (waveTracksList) {
|
||||
const tracks = data.tracks || [];
|
||||
if (tracks.length === 0) {
|
||||
waveTracksList.innerHTML = `<p class="empty-state">Волна пуста. Нажмите «Обновить Волну» для генерации первого потока.</p>`;
|
||||
} else {
|
||||
waveTracksList.innerHTML = tracks.map(t => `
|
||||
<div class="wave-track-item">
|
||||
<div style="min-width: 0; display: flex; flex-direction: column; gap: 0.15rem;">
|
||||
<strong style="font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(t.title)}</strong>
|
||||
<span style="font-size: 0.78rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
${escapeHtml(t.artist)} • ${t.size_mb} МБ (${t.age_days} дн. назад)
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.4rem; flex-shrink: 0;">
|
||||
<button class="btn-play-preview" onclick="playPreviewTrack('${escapeAttr(t.path)}', '${escapeAttr(t.title)}', '${escapeAttr(t.artist)}', '', '', 'api/stream?url=' + encodeURIComponent('${escapeAttr(t.path)}'))">
|
||||
▶ Слушать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -70,6 +70,59 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Smart Wave Card -->
|
||||
<section class="card wave-card">
|
||||
<div class="wave-header">
|
||||
<div class="wave-header-title">
|
||||
<h2>🌊 Моя Волна <span class="badge badge-wave">Smart Wave V1</span></h2>
|
||||
<p class="wave-subtitle">
|
||||
Персональный поток рекомендаций на основе ваших звёздочек и скробблов. Автоматическая ротация буфера в Navidrome.
|
||||
</p>
|
||||
</div>
|
||||
<div class="wave-controls">
|
||||
<button id="waveGenerateBtn" class="btn btn-primary">⚡ Обновить Волну</button>
|
||||
<button id="waveRotateBtn" class="btn btn-outline" title="Перенести лайкнутые треки в постоянную библиотеку и очистить старые">🔄 Ротация</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wave-stats-row">
|
||||
<div class="wave-stat">
|
||||
<span class="wave-stat-label">Буфер ротации:</span>
|
||||
<strong id="waveCountLabel">0 / 25 треков</strong>
|
||||
</div>
|
||||
<div class="wave-stat">
|
||||
<span class="wave-stat-label">Объём на диске:</span>
|
||||
<strong id="waveSizeLabel">0 МБ</strong>
|
||||
</div>
|
||||
<div class="wave-stat">
|
||||
<span class="wave-stat-label">Срок хранения:</span>
|
||||
<strong>7 дней</strong>
|
||||
</div>
|
||||
<div class="wave-stat">
|
||||
<span class="wave-stat-label">Умный плейлист:</span>
|
||||
<strong><a href="https://music.qpid.pro" target="_blank">Моя Волна.nsp ↗</a></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="waveAnchorsContainer" class="wave-anchors" style="margin-top: 0.8rem; display: none;">
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary);">Якорные исполнители вкуса:</span>
|
||||
<div id="waveAnchorsList" class="wave-anchors-tags"></div>
|
||||
</div>
|
||||
|
||||
<!-- Wave Tracks List -->
|
||||
<div class="wave-tracks-container" style="margin-top: 1rem;">
|
||||
<div class="wave-tracks-header">
|
||||
<h3>🎵 Треки сейчас в потоке Волны</h3>
|
||||
<span id="waveLoadingText" style="display: none; color: var(--accent-color); font-size: 0.82rem;">
|
||||
<span class="spinner-sm"></span> Идёт поиск и загрузка рекомендаций...
|
||||
</span>
|
||||
</div>
|
||||
<div id="waveTracksList" class="wave-tracks-list">
|
||||
<p class="empty-state">Волна пуста. Нажмите «Обновить Волну» для генерации первого потока.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Active Tasks -->
|
||||
<section class="card tasks-card">
|
||||
<h2>⚡ Очередь задач</h2>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# app/wave/__init__.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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user