Initial commit - Navi.FM v3
This commit is contained in:
Vendored
BIN
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.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 you’ll 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")
|
||||
@@ -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()
|
||||
@@ -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."}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
requests
|
||||
httpx
|
||||
librosa
|
||||
soundfile
|
||||
SQLAlchemy
|
||||
jinja2
|
||||
python-dotenv
|
||||
pydantic
|
||||
pyloudnorm
|
||||
bcrypt
|
||||
cryptography
|
||||
+160
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user