Files
Navi.FM/app/main.py
T
2026-07-22 13:23:22 +02:00

521 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")