Initial commit - Navi.FM v3

This commit is contained in:
Collin Kasbergen
2026-07-22 13:23:22 +02:00
commit badafc91e3
55 changed files with 6384 additions and 0 deletions
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
import librosa
# Monkeypatch for pyloudnorm which uses the deprecated scipy.signal.hann
import scipy.signal
import scipy.signal.windows
if not hasattr(scipy.signal, 'hann'):
scipy.signal.hann = scipy.signal.windows.hann
import pyloudnorm as pyln
import numpy as np
def analyze_audio(file_path: str) -> dict:
"""Analyzes an audio file and returns metadata."""
try:
# Load audio (mono, original sample rate to preserve frequency content)
y, sr = librosa.load(file_path, sr=None, mono=True)
# BPM
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
if isinstance(tempo, np.ndarray):
bpm = float(tempo[0])
else:
bpm = float(tempo)
# Key extraction using chroma (simplified root note extraction)
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
key_idx = np.argmax(np.sum(chroma, axis=1))
keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
key = keys[key_idx]
# Energy (RMS)
rms = librosa.feature.rms(y=y)
energy = float(np.mean(rms))
# Spectral Centroid (Brightness)
cent = librosa.feature.spectral_centroid(y=y, sr=sr)
spectral_centroid = float(np.mean(cent))
# Onset Density (Danceability/Rhythm)
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
onsets = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr)
duration_sec = len(y) / sr
onset_density = len(onsets) / duration_sec if duration_sec > 0 else 0
# LUFS
meter = pyln.Meter(sr) # create BS.1770 meter
lufs = meter.integrated_loudness(y)
return {
"bpm": bpm,
"key": key,
"energy": energy,
"spectral_centroid": spectral_centroid,
"onset_density": float(onset_density),
"lufs": float(lufs)
}
except Exception as e:
print(f"Error analyzing {file_path}: {e}")
return None
+27
View File
@@ -0,0 +1,27 @@
import os
from cryptography.fernet import Fernet, InvalidToken
def get_fernet():
key = os.environ.get("ENCRYPTION_KEY")
if not key:
# Fallback to an ephemeral key if missing to prevent crashing,
# though data encrypted with this will be lost on restart
key = Fernet.generate_key()
os.environ["ENCRYPTION_KEY"] = key.decode('utf-8')
return Fernet(key)
def encrypt_password(password: str) -> str:
if not password:
return password
f = get_fernet()
return f.encrypt(password.encode('utf-8')).decode('utf-8')
def decrypt_password(token: str) -> str:
if not token:
return token
f = get_fernet()
try:
return f.decrypt(token.encode('utf-8')).decode('utf-8')
except InvalidToken:
# If decryption fails, assume it's a legacy plaintext password
return token
+69
View File
@@ -0,0 +1,69 @@
import os
from sqlalchemy import create_engine, Column, String, Float, Boolean, Integer
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import sessionmaker
DATA_DIR = os.getenv("DATA_DIR", "/data")
SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DATA_DIR, 'navifm.db')}"
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False)
Base = declarative_base()
class Track(Base):
__tablename__ = "tracks"
id = Column(String, primary_key=True, index=True) # Navidrome Track ID
title = Column(String, index=True)
artist = Column(String, index=True)
album = Column(String)
duration = Column(Integer)
path = Column(String) # Navidrome file path or relative path
# Analysis Status
analyzed = Column(Boolean, default=False)
# Analyzed Metadata
bpm = Column(Float, nullable=True)
key = Column(String, nullable=True)
lufs = Column(Float, nullable=True)
energy = Column(Float, nullable=True)
spectral_centroid = Column(Float, nullable=True)
onset_density = Column(Float, nullable=True)
# Play stats
last_played = Column(Integer, default=0) # timestamp
play_count = Column(Integer, default=0)
class Setting(Base):
__tablename__ = "settings"
key = Column(String, primary_key=True)
value = Column(String)
def get_setting(db, key: str, default: str = None):
setting = db.query(Setting).filter(Setting.key == key).first()
return setting.value if setting else default
def set_setting(db, key: str, value: str):
setting = db.query(Setting).filter(Setting.key == key).first()
if setting:
setting.value = value
else:
setting = Setting(key=key, value=value)
db.add(setting)
db.commit()
def init_db():
os.makedirs(DATA_DIR, exist_ok=True)
Base.metadata.create_all(bind=engine)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+520
View File
@@ -0,0 +1,520 @@
import os
import time
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets
import asyncio
from pydantic import BaseModel
import httpx
from fastapi.responses import StreamingResponse
from database import init_db, SessionLocal, Track, get_setting, set_setting
from worker import start_workers
from queue_manager import get_active_profile, set_active_profile, get_next_track, AVAILABLE_PROFILES
from navidrome import navidrome_client
import bcrypt
import crypto
# Initialize DB and start workers on startup
init_db()
db = SessionLocal()
try:
if get_setting(db, "theme_color") is None:
set_setting(db, "theme_color", "purple")
if get_setting(db, "app_title") is None:
set_setting(db, "app_title", "NAVI.FM")
if get_setting(db, "app_tagline") is None:
set_setting(db, "app_tagline", "The least intelligent radio software youll ever love.")
if get_setting(db, "info_modal_text") is None:
set_setting(db, "info_modal_text", "Navi.FM is an independent radio project that completely ditches traditional, static playlists. Instead, it relies on a custom, lightweight engine to intelligently shuffle and balance the unique mood, texture, and energy of every single track inside a privately curated, hand-picked music library.\n\nHow it Works\nInstead of just playing songs at random, Navi.FM uses smart audio analysis to understand how different tracks relate to each other and maps out a seamless, organic journey from one song to the next. It constantly schedules the upcoming tracks in real-time, steering the soundscape to keep the stream flowing perfectly.\n\nBehind the Scenes\nThere are no big media companies or human radio hosts pulling the strings. The entire station is a custom-built passion project run entirely out of a home setup.")
if get_setting(db, "artist_separation_enabled") is None:
set_setting(db, "artist_separation_enabled", "false")
if get_setting(db, "artist_separation_count") is None:
set_setting(db, "artist_separation_count", "5")
# Migration: Encrypt legacy plaintext Navidrome passwords
existing_nav_pwd = get_setting(db, "navidrome_password")
if existing_nav_pwd:
# Fernet tokens start with gAAAAA
if not existing_nav_pwd.startswith("gAAAAA"):
print("Migrating legacy plaintext Navidrome password to Fernet encryption...")
set_setting(db, "navidrome_password", crypto.encrypt_password(existing_nav_pwd))
finally:
db.close()
start_workers()
app = FastAPI(title="Navi.FM Backend")
security = HTTPBasic(auto_error=False)
FAILED_LOGIN_ATTEMPTS = {}
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION = 300 # 5 minutes
def verify_admin(request: Request, credentials: HTTPBasicCredentials = Depends(security)):
client_ip = request.client.host
now = time.time()
# Check rate limits
if client_ip in FAILED_LOGIN_ATTEMPTS:
FAILED_LOGIN_ATTEMPTS[client_ip] = [t for t in FAILED_LOGIN_ATTEMPTS[client_ip] if now - t < LOCKOUT_DURATION]
if len(FAILED_LOGIN_ATTEMPTS[client_ip]) >= MAX_LOGIN_ATTEMPTS:
raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.")
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
db = SessionLocal()
try:
admin_user = get_setting(db, "admin_user")
admin_pass_hash = get_setting(db, "admin_password_hash")
finally:
db.close()
if not admin_user or not admin_pass_hash:
raise HTTPException(status_code=401, detail="Admin not setup")
correct_username = secrets.compare_digest(credentials.username, admin_user)
# Use bcrypt to verify password
try:
correct_password = bcrypt.checkpw(
credentials.password.encode('utf-8'),
admin_pass_hash.encode('utf-8')
)
except Exception:
correct_password = False
if not (correct_username and correct_password):
FAILED_LOGIN_ATTEMPTS.setdefault(client_ip, []).append(now)
raise HTTPException(
status_code=401,
detail="Incorrect username or password",
)
# Clear attempts on success
if client_ip in FAILED_LOGIN_ATTEMPTS:
del FAILED_LOGIN_ATTEMPTS[client_ip]
return credentials.username
class AdminSetup(BaseModel):
username: str
password: str
class NavidromeSetup(BaseModel):
url: str
username: str
password: str
@app.get("/api/setup/status")
async def get_setup_status():
db = SessionLocal()
try:
admin_setup = get_setting(db, "admin_user") is not None
nav_setup = get_setting(db, "navidrome_url") is not None
analyzed_tracks = db.query(Track).filter(Track.analyzed == True).count()
return {
"is_setup": admin_setup and nav_setup and analyzed_tracks > 10,
"admin_setup": admin_setup,
"navidrome_setup": nav_setup,
"analyzed_tracks": analyzed_tracks
}
finally:
db.close()
@app.post("/api/setup/admin")
async def setup_admin(data: AdminSetup):
db = SessionLocal()
try:
if get_setting(db, "admin_user"):
raise HTTPException(status_code=400, detail="Admin already setup")
hashed_password = bcrypt.hashpw(data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
set_setting(db, "admin_user", data.username)
set_setting(db, "admin_password_hash", hashed_password)
return {"status": "success"}
finally:
db.close()
@app.post("/api/setup/navidrome")
async def setup_navidrome(data: NavidromeSetup):
db = SessionLocal()
try:
if get_setting(db, "navidrome_url"):
raise HTTPException(status_code=400, detail="Navidrome already setup")
from navidrome import NavidromeClient
is_valid = NavidromeClient.verify_credentials(data.url, data.username, data.password)
if not is_valid:
raise HTTPException(status_code=400, detail="Could not connect to Navidrome server. Please check your URL and credentials.")
set_setting(db, "navidrome_url", data.url.rstrip("/"))
set_setting(db, "navidrome_user", data.username)
set_setting(db, "navidrome_password", crypto.encrypt_password(data.password))
return {"status": "success"}
finally:
db.close()
class ProfileUpdate(BaseModel):
profile: str
@app.get("/api/profiles")
async def get_profiles(_: str = Depends(verify_admin)):
db = SessionLocal()
try:
active_profile = get_active_profile(db)
track_count = db.query(Track).filter(Track.analyzed == True).count()
total_track_count = navidrome_client.get_total_songs()
finally:
db.close()
return {
"profiles": AVAILABLE_PROFILES,
"active_profile": active_profile,
"track_count": track_count,
"total_track_count": total_track_count
}
@app.post("/api/profile")
async def update_profile(data: ProfileUpdate, _: str = Depends(verify_admin)):
valid_ids = [p["id"] for p in AVAILABLE_PROFILES]
if data.profile not in valid_ids:
raise HTTPException(status_code=400, detail="Invalid profile")
set_active_profile(data.profile)
return {"status": "success", "profile": data.profile}
class SettingsUpdate(BaseModel):
theme_color: str
app_title: str
app_tagline: str
info_modal_text: str
favicon_svg_overlay: str = ""
favicon_svg_color: str = "white"
artist_separation_enabled: bool = False
artist_separation_count: int = 5
auto_pilot_schedule: str = "[]"
@app.get("/api/settings")
async def read_settings():
db = SessionLocal()
try:
return {
"theme_color": get_setting(db, "theme_color", "purple"),
"app_title": get_setting(db, "app_title", "COLIE.FM"),
"app_tagline": get_setting(db, "app_tagline", ""),
"info_modal_text": get_setting(db, "info_modal_text", ""),
"favicon_svg_overlay": get_setting(db, "favicon_svg_overlay", ""),
"favicon_svg_color": get_setting(db, "favicon_svg_color", "white"),
"artist_separation_enabled": get_setting(db, "artist_separation_enabled", "false") == "true",
"artist_separation_count": int(get_setting(db, "artist_separation_count", "5")),
"auto_pilot_schedule": get_setting(db, "auto_pilot_schedule", "[]")
}
finally:
db.close()
@app.post("/api/settings")
async def update_settings(data: SettingsUpdate, _: str = Depends(verify_admin)):
db = SessionLocal()
try:
set_setting(db, "theme_color", data.theme_color)
set_setting(db, "app_title", data.app_title)
set_setting(db, "app_tagline", data.app_tagline)
set_setting(db, "info_modal_text", data.info_modal_text)
set_setting(db, "favicon_svg_overlay", data.favicon_svg_overlay)
set_setting(db, "favicon_svg_color", data.favicon_svg_color)
set_setting(db, "artist_separation_enabled", "true" if data.artist_separation_enabled else "false")
set_setting(db, "artist_separation_count", str(data.artist_separation_count))
set_setting(db, "auto_pilot_schedule", data.auto_pilot_schedule)
finally:
db.close()
return {"status": "success"}
@app.get("/api/history")
async def get_history(_: str = Depends(verify_admin)):
"""
Returns the last 10 tracks played for the admin/debug panel.
"""
now_data = await now_playing()
return {"history": now_data.get("history", [])}
import socket
@app.post("/api/skip")
async def skip_track(_: str = Depends(verify_admin)):
"""
Sends a telnet command to Liquidsoap to skip the current track.
"""
try:
# Connect to liquidsoap telnet server
with socket.create_connection(("127.0.0.1", 1234), timeout=2) as sock:
# Send the skip command for the navifm_queue output node
sock.sendall(b"navifm_queue.skip\r\nquit\r\n")
# Read the 'Done' response to ensure it was processed
sock.recv(4096)
return {"status": "success", "message": "Track skipped"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to skip track: {str(e)}")
@app.get("/next", response_class=PlainTextResponse)
async def next_track(request: Request):
"""
Called by Liquidsoap to get the URL of the next track to play.
Restricted to localhost for security.
"""
if request.client.host != "127.0.0.1":
raise HTTPException(status_code=403, detail="Forbidden: Internal endpoint")
track_data = get_next_track()
if track_data:
# Generate the stream URL from Navidrome
url = navidrome_client.get_stream_url(track_data["id"])
lufs = track_data.get("lufs")
if lufs is not None:
# Calculate ratio to hit target of -14 LUFS (common streaming standard)
target_lufs = -14.0
delta_db = target_lufs - lufs
# Cap the adjustment between -15dB and +10dB to avoid extreme boosting
delta_db = min(max(delta_db, -15.0), 10.0)
ratio = 10 ** (delta_db / 20.0)
# Use Liquidsoap's annotate protocol to pass the custom amplify metadata
return f'annotate:liq_amplify="{ratio:.4f}":{url}'
return url
else:
# Return empty or some fallback if queue is entirely empty
# Sleep for 5 seconds to prevent liquidsoap from looping instantly and spamming logs
await asyncio.sleep(5)
return ""
@app.get("/health")
async def health_check():
return {"status": "healthy"}
@app.get("/stream")
async def proxy_stream(request: Request):
"""
Proxies the Icecast audio stream so the frontend doesn't need to connect to port 8000 directly.
"""
client = httpx.AsyncClient(timeout=None)
# Pre-flight request to get headers
req = client.build_request("GET", "http://127.0.0.1:8000/stream")
response = await client.send(req, stream=True)
headers = {}
for k, v in response.headers.items():
if k.lower().startswith('icy-') or k.lower() == 'content-type':
headers[k] = v
async def stream_generator():
try:
async for chunk in response.aiter_bytes():
if await request.is_disconnected():
break
yield chunk
except Exception:
pass
finally:
await response.aclose()
await client.aclose()
return StreamingResponse(stream_generator(), headers=headers)
import time
current_playing_state = {
"rid": None,
"start_time": int(time.time()),
"track_dict": None,
"history": None
}
@app.get("/api/internal/track_changed")
async def track_changed(request: Request, title: str = None, artist: str = None):
"""
Webhook called by Liquidsoap exactly when a new track starts playing.
Restricted to localhost for security.
"""
if request.client.host != "127.0.0.1":
raise HTTPException(status_code=403, detail="Forbidden: Internal endpoint")
global current_playing_state
# 1. Pop previous track into history
if current_playing_state["track_dict"] and current_playing_state["track_dict"].get("title") != "Unknown":
old_track = current_playing_state["track_dict"].copy()
old_track["last_played"] = int(time.time())
if current_playing_state["history"] is None:
current_playing_state["history"] = []
current_playing_state["history"].insert(0, old_track)
if len(current_playing_state["history"]) > 10:
current_playing_state["history"].pop()
# 2. Fetch new track metadata from DB
db = SessionLocal()
try:
if title and artist:
track = db.query(Track).filter(Track.title == title, Track.artist == artist).first()
if track:
current_playing_state["track_dict"] = {
"id": track.id,
"title": track.title,
"artist": track.artist,
"bpm": track.bpm,
"energy": track.energy,
"lufs": track.lufs
}
else:
current_playing_state["track_dict"] = {"id": "", "title": title, "artist": artist, "bpm": 120, "energy": 0.5, "lufs": -14.0}
else:
current_playing_state["track_dict"] = {"id": "", "title": "Unknown", "artist": "Unknown", "bpm": 120, "energy": 0.5, "lufs": -14.0}
current_playing_state["start_time"] = int(time.time())
finally:
db.close()
return {"status": "ok"}
@app.get("/api/now-playing")
async def now_playing():
"""
Returns the metadata of the currently playing track for the frontend UI.
"""
global current_playing_state
# If UI requests before first track_changed webhook hits, return a fallback
if not current_playing_state["track_dict"]:
db = SessionLocal()
try:
from queue_manager import get_current_track
current = get_current_track()
if current:
current_playing_state["track_dict"] = current
else:
current_playing_state["track_dict"] = {"id": "", "title": "Unknown", "artist": "Unknown", "bpm": 120, "energy": 0.5, "lufs": -14.0}
finally:
db.close()
# Seed history from DB if empty on first boot
if current_playing_state["history"] is None:
db = SessionLocal()
try:
recent_tracks = db.query(Track).filter(Track.last_played > 0).order_by(Track.last_played.desc()).limit(15).all()
h_list = []
v_time = int(time.time())
# Very rough approximation just so UI isn't empty on boot
for t in recent_tracks[:10]:
if t.title != current_playing_state["track_dict"].get("title"):
h_list.append({
"id": t.id,
"title": t.title,
"artist": t.artist,
"last_played": v_time
})
v_time -= (t.duration if t.duration else 180)
current_playing_state["history"] = h_list
finally:
db.close()
# Return state
res = current_playing_state["track_dict"].copy()
res["history"] = current_playing_state["history"]
res["server_time"] = int(time.time())
return res
from fastapi.staticfiles import StaticFiles
import os
import re
import time
from fastapi.responses import HTMLResponse, Response
frontend_dist = "/app/frontend/dist"
@app.get("/favicon.svg")
async def serve_favicon():
db = SessionLocal()
try:
theme_color = get_setting(db, "theme_color", "purple")
overlay = get_setting(db, "favicon_svg_overlay", "")
svg_color = get_setting(db, "favicon_svg_color", "white")
finally:
db.close()
palette = {
"purple": {"glow": "#8b5cf6"},
"blue": {"glow": "#3b82f6"},
"cyan": {"glow": "#06b6d4"},
"green": {"glow": "#10b981"},
"yellow": {"glow": "#eab308"},
"orange": {"glow": "#f97316"},
"red": {"glow": "#ef4444"},
"pink": {"glow": "#ec4899"}
}
glow = palette.get(theme_color, palette["purple"])["glow"]
overlay_content = ""
if overlay:
# Remove hardcoded width/height
processed = re.sub(r'\b(width|height)=("[^"]*"|\'[^\']*\')', '', overlay, flags=re.IGNORECASE)
processed = re.sub(r'<svg\b', '<svg width="100%" height="100%" ', processed, flags=re.IGNORECASE)
overlay_content = f'<svg x="20" y="20" width="60" height="60" class="overlay-container">{processed}</svg>'
svg_string = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" style="background: transparent; color: {svg_color};">
<style>
.overlay-container path:not([fill="none"]),
.overlay-container rect:not([fill="none"]),
.overlay-container circle:not([fill="none"]),
.overlay-container polygon:not([fill="none"]),
.overlay-container polyline:not([fill="none"]),
.overlay-container ellipse:not([fill="none"]) {{
fill: {svg_color} !important;
}}
.overlay-container [stroke]:not([stroke="none"]) {{
stroke: {svg_color} !important;
}}
</style>
<circle cx="50" cy="50" r="50" fill="{glow}" />
{overlay_content}
</svg>'''
return Response(content=svg_string, media_type="image/svg+xml", headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
@app.get("/")
async def serve_index():
index_path = os.path.join(frontend_dist, "index.html")
if not os.path.exists(index_path):
return HTMLResponse("Frontend not built")
db = SessionLocal()
try:
app_title = get_setting(db, "app_title", "NAVI.FM")
finally:
db.close()
with open(index_path, "r") as f:
html = f.read()
# Replace title
html = re.sub(r"<title>.*?</title>", f"<title>{app_title}</title>", html)
# Inject favicon link
cache_bust = int(time.time())
favicon_link = f'<link rel="icon" href="/favicon.svg?v={cache_bust}" type="image/svg+xml" />'
html = html.replace("</head>", f" {favicon_link}\n</head>")
return HTMLResponse(content=html)
# Serve the Vue frontend built files if they exist
if os.path.exists(frontend_dist):
# Mount frontend dist at root but do not serve index.html directly for /
app.mount("/", StaticFiles(directory=frontend_dist, html=False), name="static")
+153
View File
@@ -0,0 +1,153 @@
import os
import hashlib
import random
import string
import requests
from typing import List, Dict
class NavidromeClient:
def __init__(self):
self.version = "1.16.1"
self.client_name = "navifm"
@staticmethod
def verify_credentials(url: str, username: str, password: str) -> bool:
salt = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(6))
token = hashlib.md5((password + salt).encode('utf-8')).hexdigest()
params = {
"u": username,
"t": token,
"s": salt,
"v": "1.16.1",
"c": "navifm",
"f": "json"
}
try:
url_clean = url.rstrip('/')
response = requests.get(f"{url_clean}/rest/ping", params=params, timeout=10)
response.raise_for_status()
data = response.json()
if "subsonic-response" in data and data["subsonic-response"]["status"] == "ok":
return True
return False
except Exception as e:
print(f"Credentials verification failed: {e}")
return False
@property
def url(self):
from database import SessionLocal, get_setting
db = SessionLocal()
try:
return get_setting(db, "navidrome_url", "")
finally:
db.close()
@property
def user(self):
from database import SessionLocal, get_setting
db = SessionLocal()
try:
return get_setting(db, "navidrome_user", "")
finally:
db.close()
@property
def password(self):
from database import SessionLocal, get_setting
import crypto
db = SessionLocal()
try:
enc_pwd = get_setting(db, "navidrome_password", "")
return crypto.decrypt_password(enc_pwd)
finally:
db.close()
def _generate_salt(self, length=6):
return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length))
def _get_auth_params(self):
salt = self._generate_salt()
token = hashlib.md5((self.password + salt).encode('utf-8')).hexdigest()
return {
"u": self.user,
"t": token,
"s": salt,
"v": self.version,
"c": self.client_name,
"f": "json"
}
def _request(self, endpoint: str, params: Dict = None):
if not self.url or not self.user or not self.password:
print("Navidrome credentials not configured.")
return None
url = f"{self.url}/rest/{endpoint}"
payload = self._get_auth_params()
if params:
payload.update(params)
try:
response = requests.get(url, params=payload, timeout=10)
response.raise_for_status()
data = response.json()
if "subsonic-response" in data and data["subsonic-response"]["status"] == "ok":
return data["subsonic-response"]
else:
print(f"Subsonic Error: {data}")
return None
except Exception as e:
print(f"Request failed: {e}")
return None
def get_random_songs(self, count=100) -> List[Dict]:
"""Fetch random songs to discover tracks for the library."""
res = self._request("getRandomSongs", {"size": count})
if res and "randomSongs" in res and "song" in res["randomSongs"]:
return res["randomSongs"]["song"]
return []
def get_total_songs(self) -> int:
"""Attempts to get the total number of songs from Navidrome."""
if not self.url or not self.user or not self.password:
return 0
try:
url_clean = self.url.rstrip('/')
# Try native Navidrome API (requires real password, not subsonic token)
login_res = requests.post(f"{url_clean}/auth/login", json={"username": self.user, "password": self.password}, timeout=5)
if login_res.ok:
token = login_res.json().get("token")
if token:
headers = {"x-access-token": token}
song_res = requests.get(f"{url_clean}/api/song?_end=1", headers=headers, timeout=5)
if song_res.ok and "x-total-count" in song_res.headers:
return int(song_res.headers["x-total-count"])
except Exception as e:
print(f"Failed native API count: {e}")
return 0
def get_stream_url(self, song_id: str) -> str:
"""Returns the full authenticated URL to stream a song."""
params = self._get_auth_params()
params["id"] = song_id
# We also might want to specify format, but usually Navidrome handles it
query_string = "&".join(f"{k}={v}" for k, v in params.items())
return f"{self.url}/rest/stream?{query_string}"
def download_song(self, song_id: str, dest_path: str):
"""Downloads a song to disk for analysis."""
stream_url = self.get_stream_url(song_id)
try:
with requests.get(stream_url, stream=True, timeout=30) as r:
r.raise_for_status()
with open(dest_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
return True
except Exception as e:
print(f"Failed to download song {song_id}: {e}")
return False
navidrome_client = NavidromeClient()
+111
View File
@@ -0,0 +1,111 @@
import time
import random
from sqlalchemy.sql.expression import func
from database import SessionLocal, Track, Setting
def get_active_profile(db):
setting = db.query(Setting).filter(Setting.key == "active_profile").first()
return setting.value if setting else "harmonic"
def set_active_profile(profile_name: str):
db = SessionLocal()
try:
setting = db.query(Setting).filter(Setting.key == "active_profile").first()
if not setting:
setting = Setting(key="active_profile", value=profile_name)
db.add(setting)
else:
setting.value = profile_name
db.commit()
finally:
db.close()
def get_next_track():
db = SessionLocal()
try:
profile = get_active_profile(db)
track = None
# Only select from analyzed tracks
base_query = db.query(Track).filter(Track.analyzed == True)
# Avoid playing tracks that were played in the last hour
one_hour_ago = int(time.time()) - 3600
pool_query = base_query.filter(Track.last_played < one_hour_ago)
# Artist Separation Logic
artist_sep_enabled = db.query(Setting).filter(Setting.key == "artist_separation_enabled").first()
if artist_sep_enabled and artist_sep_enabled.value == "true":
artist_sep_count_setting = db.query(Setting).filter(Setting.key == "artist_separation_count").first()
sep_count = int(artist_sep_count_setting.value) if artist_sep_count_setting else 5
recent_tracks = db.query(Track).filter(Track.last_played > 0).order_by(Track.last_played.desc()).limit(sep_count).all()
recent_artists = [t.artist for t in recent_tracks if t.artist]
if recent_artists:
filtered_query = pool_query.filter(~Track.artist.in_(recent_artists))
if filtered_query.count() > 0:
pool_query = filtered_query
if pool_query.count() == 0:
# Fallback if all tracks were played recently or no tracks analyzed
pool_query = base_query
if profile == "high_energy":
# Pick from most energetic tracks
tracks = pool_query.order_by(Track.energy.desc()).limit(50).all()
if tracks:
track = random.choice(tracks)
elif profile == "chill":
# Pick from least energetic tracks
tracks = pool_query.order_by(Track.energy.asc()).limit(50).all()
if tracks:
track = random.choice(tracks)
elif profile == "harmonic":
# Get last played track to match key/bpm
last_played = base_query.order_by(Track.last_played.desc()).first()
if last_played and last_played.key and last_played.bpm:
# Find matching key and similar BPM (+- 10)
track = pool_query.filter(Track.key == last_played.key)\
.filter(Track.bpm.between(last_played.bpm - 10, last_played.bpm + 10))\
.order_by(func.random()).first()
# Fallback to random if no track found for profile or profile is 'random'
if not track:
track = pool_query.order_by(func.random()).first()
if track:
# Update last played
track.last_played = int(time.time())
track.play_count += 1
db.commit()
return {"id": track.id, "lufs": track.lufs}
return None
finally:
db.close()
def get_current_track():
db = SessionLocal()
try:
# The currently playing track is approximated as the most recently queued track
track = db.query(Track).order_by(Track.last_played.desc()).first()
if track and track.last_played > 0:
return {
"id": track.id,
"title": track.title,
"artist": track.artist,
"bpm": track.bpm,
"energy": track.energy,
"lufs": track.lufs
}
return None
finally:
db.close()
# Define available profiles for the WebUI
AVAILABLE_PROFILES = [
{"id": "harmonic", "name": "Harmonic Mix", "description": "Attempts to match the key and BPM of the previously played track."},
{"id": "high_energy", "name": "High Energy", "description": "Prioritizes tracks with high RMS energy."},
{"id": "chill", "name": "Chill", "description": "Prioritizes low-energy, ambient tracks."},
{"id": "random", "name": "Random", "description": "Plays tracks randomly."}
]
+13
View File
@@ -0,0 +1,13 @@
fastapi
uvicorn[standard]
requests
httpx
librosa
soundfile
SQLAlchemy
jinja2
python-dotenv
pydantic
pyloudnorm
bcrypt
cryptography
View File
+160
View File
@@ -0,0 +1,160 @@
import time
import os
import threading
import subprocess
from database import SessionLocal, Track
from navidrome import navidrome_client
from analyzer import analyze_audio
from queue_manager import get_active_profile, set_active_profile
import json
import datetime
def sync_tracks_loop():
while True:
try:
if not navidrome_client.url:
time.sleep(10)
continue
print("Syncing tracks from Navidrome...")
songs = navidrome_client.get_random_songs(count=200)
db = SessionLocal()
added = 0
for song in songs:
# Check if song exists
existing = db.query(Track).filter(Track.id == song["id"]).first()
if not existing:
new_track = Track(
id=song["id"],
title=song.get("title", "Unknown"),
artist=song.get("artist", "Unknown"),
album=song.get("album", "Unknown"),
duration=song.get("duration", 0),
path=song.get("path", "")
)
db.add(new_track)
added += 1
if added > 0:
db.commit()
db.close()
print(f"Sync complete. Added {added} new tracks.")
except Exception as e:
print(f"Error syncing tracks: {e}")
# Run every hour
time.sleep(3600)
def analyze_tracks_loop():
while True:
try:
if not navidrome_client.url:
time.sleep(10)
continue
db = SessionLocal()
# Find an un-analyzed track
track = db.query(Track).filter(Track.analyzed == False).first()
if track:
print(f"Analyzing track: {track.artist} - {track.title}")
temp_path = f"/tmp/{track.id}.audio"
# Download
if navidrome_client.download_song(track.id, temp_path):
# Analyze
metadata = analyze_audio(temp_path)
if metadata:
track.bpm = metadata["bpm"]
track.key = metadata["key"]
track.energy = metadata["energy"]
track.spectral_centroid = metadata["spectral_centroid"]
track.onset_density = metadata["onset_density"]
track.lufs = metadata["lufs"]
track.analyzed = True
db.commit()
print("Analysis successful.")
else:
# Mark as analyzed anyway so we don't get stuck in a loop, or maybe add an error flag
track.analyzed = True
db.commit()
print("Analysis failed, marked as analyzed to skip.")
# Cleanup
if os.path.exists(temp_path):
os.remove(temp_path)
else:
print("Download failed.")
else:
# No tracks to analyze, sleep for a while
time.sleep(60)
db.close()
except Exception as e:
print(f"Error in analysis loop: {e}")
time.sleep(60)
def scheduler_loop():
"""
Checks the auto_pilot_schedule every minute and changes the profile if it matches the current time.
"""
while True:
try:
# Wait until the start of the next minute
now = datetime.datetime.now()
sleep_seconds = 60 - now.second
time.sleep(sleep_seconds)
# Now it's a new minute
now = datetime.datetime.now()
current_time_str = now.strftime("%H:%M")
db = SessionLocal()
from database import get_setting
schedule_str = get_setting(db, "auto_pilot_schedule", "[]")
db.close()
schedule = json.loads(schedule_str)
for rule in schedule:
if rule.get("time") == current_time_str:
target_profile = rule.get("profile")
if target_profile:
print(f"[Scheduler] Time is {current_time_str}. Switching profile to: {target_profile}")
set_active_profile(target_profile)
except Exception as e:
print(f"Error in scheduler loop: {e}")
time.sleep(60)
def startup_manager_loop():
"""
Waits until at least 10 tracks have been analyzed before starting Liquidsoap.
This prevents Liquidsoap from rapidly requesting /next and queuing the exact same track
repeatedly when the database is empty or only has 1 track during initial setup.
"""
while True:
try:
db = SessionLocal()
count = db.query(Track).filter(Track.analyzed == True).count()
db.close()
if count >= 10:
print(f"[Startup Manager] {count} tracks analyzed. Starting Liquidsoap...")
# Run supervisorctl to start liquidsoap
subprocess.run(["supervisorctl", "start", "liquidsoap"], check=False)
break
else:
print(f"[Startup Manager] Waiting for initial analysis... ({count}/10 tracks analyzed)")
time.sleep(10)
except Exception as e:
print(f"Error in startup manager: {e}")
time.sleep(10)
def start_workers():
t1 = threading.Thread(target=sync_tracks_loop, daemon=True)
t2 = threading.Thread(target=analyze_tracks_loop, daemon=True)
t3 = threading.Thread(target=scheduler_loop, daemon=True)
t4 = threading.Thread(target=startup_manager_loop, daemon=True)
t1.start()
t2.start()
t3.start()
t4.start()