commit badafc91e35f13ac7512ae72391d28a113ec2c64 Author: Collin Kasbergen Date: Wed Jul 22 13:23:22 2026 +0200 Initial commit - Navi.FM v3 diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9fba6aa Binary files /dev/null and b/.DS_Store differ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/coliefm-v3.iml b/.idea/coliefm-v3.iml new file mode 100644 index 0000000..8b8c395 --- /dev/null +++ b/.idea/coliefm-v3.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..48f9b4e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c8e8219 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# ---- Stage 1: Build Frontend ---- +FROM node:22-alpine AS frontend-builder +WORKDIR /frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend . +RUN npm run build + +# ---- Stage 2: Final Image ---- +FROM savonet/liquidsoap:v2.2.5 + +# Switch to root to install packages +USER root + +# Install required packages +# - icecast2: streaming server +# - supervisor: process manager +# - python3-venv, python3-pip: for the backend +# - ffmpeg, libsndfile1: backend audio processing +RUN apt-get update && apt-get install -y \ + icecast2 \ + supervisor \ + python3 \ + python3-venv \ + python3-pip \ + ffmpeg \ + libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +# Set up Icecast directories and permissions +RUN mkdir -p /var/log/icecast2 /etc/icecast2 /var/run/icecast2 \ + && chown -R liquidsoap:liquidsoap /var/log/icecast2 /etc/icecast2 /var/run/icecast2 + +COPY icecast.xml /etc/icecast2/icecast.xml + +# Prepare python environment +WORKDIR /app +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY app/requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend code +COPY app /app/app + +# Copy liquidsoap config +COPY liquidsoap /app/liquidsoap + +# Copy built frontend from Stage 1 +COPY --from=frontend-builder /frontend/dist /app/frontend/dist + +# Copy supervisord config +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +# Give liquidsoap user permissions to /app and /data +RUN mkdir -p /data \ + && chown -R liquidsoap:liquidsoap /app /data /opt/venv + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Expose required ports: 8080 (API/Web UI), 8000 (Icecast) +EXPOSE 8080 8000 + +# Start via entrypoint (runs as root to fix permissions, then starts supervisord) +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..9042784 Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/__pycache__/analyzer.cpython-311.pyc b/app/__pycache__/analyzer.cpython-311.pyc new file mode 100644 index 0000000..a5e99d5 Binary files /dev/null and b/app/__pycache__/analyzer.cpython-311.pyc differ diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000..f60d19e Binary files /dev/null and b/app/__pycache__/database.cpython-311.pyc differ diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000..8b82839 Binary files /dev/null and b/app/__pycache__/main.cpython-311.pyc differ diff --git a/app/__pycache__/navidrome.cpython-311.pyc b/app/__pycache__/navidrome.cpython-311.pyc new file mode 100644 index 0000000..a7ceb1a Binary files /dev/null and b/app/__pycache__/navidrome.cpython-311.pyc differ diff --git a/app/__pycache__/queue_manager.cpython-311.pyc b/app/__pycache__/queue_manager.cpython-311.pyc new file mode 100644 index 0000000..118de1a Binary files /dev/null and b/app/__pycache__/queue_manager.cpython-311.pyc differ diff --git a/app/__pycache__/worker.cpython-311.pyc b/app/__pycache__/worker.cpython-311.pyc new file mode 100644 index 0000000..6e8fc51 Binary files /dev/null and b/app/__pycache__/worker.cpython-311.pyc differ diff --git a/app/analyzer.py b/app/analyzer.py new file mode 100644 index 0000000..6b00382 --- /dev/null +++ b/app/analyzer.py @@ -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 diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..3e537ad --- /dev/null +++ b/app/crypto.py @@ -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 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2ec3638 --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..aea2995 --- /dev/null +++ b/app/main.py @@ -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'{processed}' + + svg_string = f''' + + + {overlay_content} +''' + + 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".*?", f"{app_title}", html) + + # Inject favicon link + cache_bust = int(time.time()) + favicon_link = f'' + html = html.replace("", f" {favicon_link}\n") + + 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") diff --git a/app/navidrome.py b/app/navidrome.py new file mode 100644 index 0000000..03bc861 --- /dev/null +++ b/app/navidrome.py @@ -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() diff --git a/app/queue_manager.py b/app/queue_manager.py new file mode 100644 index 0000000..fcf6212 --- /dev/null +++ b/app/queue_manager.py @@ -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."} +] diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..76aa417 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,13 @@ +fastapi +uvicorn[standard] +requests +httpx +librosa +soundfile +SQLAlchemy +jinja2 +python-dotenv +pydantic +pyloudnorm +bcrypt +cryptography diff --git a/app/settings.db b/app/settings.db new file mode 100644 index 0000000..e69de29 diff --git a/app/worker.py b/app/worker.py new file mode 100644 index 0000000..5122b96 --- /dev/null +++ b/app/worker.py @@ -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() + diff --git a/data/.DS_Store b/data/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/data/.DS_Store differ diff --git a/data/navifm.db b/data/navifm.db new file mode 100644 index 0000000..a4d4b2a Binary files /dev/null and b/data/navifm.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..68b2262 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + navifm: + build: + context: . + dockerfile: Dockerfile + container_name: navifm + ports: + - "8080:8080" # API and Web UI + volumes: + - ./data:/data + environment: + - DATA_DIR=/data + - TZ=Europe/Amsterdam + + # If you change ICECAST_PASSWORD, you must also update icecast.xml! + - ICECAST_PASSWORD=hackme + restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..365a922 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +# Generate or load secure Icecast password +if [ -n "$ICECAST_PASSWORD" ] && [ "$ICECAST_PASSWORD" != "hackme" ]; then + echo "Using user-provided Icecast password." + echo "$ICECAST_PASSWORD" > /data/.icecast_password +elif [ ! -f /data/.icecast_password ]; then + echo "Generating secure Icecast password..." + # Generate a 24-character random alphanumeric password + cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 24 | head -n 1 > /data/.icecast_password +fi + +export ICECAST_PASSWORD=$(cat /data/.icecast_password) + +# Inject password into icecast.xml +sed -i "s/.*<\/source-password>/${ICECAST_PASSWORD}<\/source-password>/g" /etc/icecast2/icecast.xml +sed -i "s/.*<\/relay-password>/${ICECAST_PASSWORD}<\/relay-password>/g" /etc/icecast2/icecast.xml +sed -i "s/.*<\/admin-password>/${ICECAST_PASSWORD}<\/admin-password>/g" /etc/icecast2/icecast.xml + +# Generate or load symmetric encryption key for DB credentials +if [ -n "$ENCRYPTION_KEY" ]; then + echo "Using user-provided Encryption Key." + echo "$ENCRYPTION_KEY" > /data/.encryption_key +elif [ ! -f /data/.encryption_key ]; then + echo "Generating secure Encryption Key..." + # Generate a Fernet-compatible URL-safe base64 encoded 32-byte key + python3 -c "import base64; import os; print(base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8'))" > /data/.encryption_key +fi + +export ENCRYPTION_KEY=$(cat /data/.encryption_key) + +# Fix permissions on /data volume so the liquidsoap user can read/write the SQLite database +chown -R liquidsoap:liquidsoap /data + +# Execute the main process (supervisord) +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..72c02af --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1130 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "three": "^0.185.1", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cba47ba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "three": "^0.185.1", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..ac8ef98 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue new file mode 100644 index 0000000..0beef97 --- /dev/null +++ b/frontend/src/components/AdminPanel.vue @@ -0,0 +1,1405 @@ + + + + + diff --git a/frontend/src/components/AudioPlayer.vue b/frontend/src/components/AudioPlayer.vue new file mode 100644 index 0000000..1dc04b2 --- /dev/null +++ b/frontend/src/components/AudioPlayer.vue @@ -0,0 +1,972 @@ + + + + + diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..f91553d --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/components/SetupWizard.vue b/frontend/src/components/SetupWizard.vue new file mode 100644 index 0000000..35dee63 --- /dev/null +++ b/frontend/src/components/SetupWizard.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/frontend/src/components/ThreeOrb.vue b/frontend/src/components/ThreeOrb.vue new file mode 100644 index 0000000..01e4b9e --- /dev/null +++ b/frontend/src/components/ThreeOrb.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/frontend/src/store/player.js b/frontend/src/store/player.js new file mode 100644 index 0000000..74a2663 --- /dev/null +++ b/frontend/src/store/player.js @@ -0,0 +1,173 @@ +import { reactive } from 'vue' +import { settingsState } from './settings' + +const streamUrl = `/stream` + +export const playerState = reactive({ + isPlaying: false, + isLoading: false, + isMuted: false, + volume: 0.8, + error: null, + isStreamOnline: false, + currentTrack: null, + bpm: 120, + intensity: 0.5, + brightness: 0.5 +}) + +let audio = null +let pollInterval = null + +// Fetch currently playing track metadata +export const fetchNowPlaying = async () => { + try { + const response = await fetch('/api/now-playing') + if (response.ok) { + playerState.isStreamOnline = true + const data = await response.json() + playerState.currentTrack = data + + if (data.listeners !== undefined) { + settingsState.listenerCount = data.listeners + } + + if (data.bpm) playerState.bpm = data.bpm + + if (data.energy !== undefined) { + playerState.intensity = Math.max(0.2, Math.min(1.0, data.energy)) + } + + if (data.lufs !== undefined) { + const lufsVal = data.lufs + let b = (lufsVal + 24) / 19 + playerState.brightness = Math.max(0.2, Math.min(1.0, b)) + } + + if (data.history) { + settingsState.history = data.history + } + + if (data.server_time) { + settingsState.serverTimeOffset = Math.floor(Date.now() / 1000) - data.server_time + } + } else { + playerState.isStreamOnline = false + } + } catch (err) { + console.error('Failed to fetch now-playing data:', err) + playerState.isStreamOnline = false + } +} + +// Initialize and bind audio listeners +const initAudio = () => { + if (audio) return + + audio = new Audio() + + audio.addEventListener('playing', () => { + playerState.isLoading = false + playerState.isPlaying = true + playerState.error = null + }) + + audio.addEventListener('waiting', () => { + playerState.isLoading = true + }) + + audio.addEventListener('error', (e) => { + console.error('Audio element error:', e) + const code = audio.error ? audio.error.code : 'unknown' + let msg = `Stream offline (code: ${code})` + if (code === 4) msg = `Stream offline or unsupported format.` + if (code === 2) msg = `Network error connecting to stream.` + playerState.error = msg + playerState.isLoading = false + playerState.isPlaying = false + cleanupAudio() + }) +} + +// Cleanup audio resource +export const cleanupAudio = () => { + if (audio) { + try { + audio.pause() + audio.removeAttribute('src') + audio.load() + } catch (e) { + console.error('Error during cleanup:', e) + } + audio = null + } +} + +// Toggle Play / Pause +export const togglePlay = () => { + if (playerState.isPlaying) { + pauseStream() + } else { + playStream() + } +} + +// Play stream (dynamically loading it to get fresh live feed) +export const playStream = async () => { + playerState.error = null + playerState.isLoading = true + + try { + initAudio() + // Append timestamp to bypass browser caching of dead stream connections + audio.src = `${streamUrl}?cb=${Date.now()}` + audio.volume = playerState.isMuted ? 0 : playerState.volume + audio.load() + + await audio.play() + } catch (err) { + console.error('Playback failed:', err) + playerState.error = `Playback blocked (${err.name || 'unknown error'})` + playerState.isLoading = false + playerState.isPlaying = false + cleanupAudio() + } +} + +// Pause stream (unloads stream to save bandwidth and keep live alignment) +export const pauseStream = () => { + playerState.isPlaying = false + playerState.isLoading = false + cleanupAudio() +} + +// Toggle Mute +export const toggleMute = () => { + playerState.isMuted = !playerState.isMuted + if (audio) { + audio.volume = playerState.isMuted ? 0 : playerState.volume + } +} + +// Set Volume directly +export const setVolume = (newVal) => { + playerState.volume = newVal + if (audio) { + audio.volume = playerState.isMuted ? 0 : newVal + } +} + +// Start Background Polling (Called exactly once in App.vue or AudioPlayer) +export const startPolling = () => { + fetchNowPlaying() + if (!pollInterval) { + pollInterval = setInterval(fetchNowPlaying, 5000) + } +} + +export const stopPolling = () => { + if (pollInterval) { + clearInterval(pollInterval) + pollInterval = null + } +} diff --git a/frontend/src/store/settings.js b/frontend/src/store/settings.js new file mode 100644 index 0000000..e762043 --- /dev/null +++ b/frontend/src/store/settings.js @@ -0,0 +1,146 @@ +import { reactive } from 'vue' + +export const settingsState = reactive({ + themeColor: 'purple', + appTitle: 'NAVI.FM', + appTagline: 'The least intelligent radio software you’ll ever love.', + infoModalText: '', + artistSeparationEnabled: false, + artistSeparationCount: 5, + faviconSvgOverlay: '', + faviconSvgColor: 'white', + autoPilotSchedule: [], + history: [], + listenerCount: 0, + isLoading: false, + error: null, + serverTimeOffset: 0 +}) + +// The 8 primary theme colors available for branding +export const THEME_PALETTE = { + purple: { + glow: '#8b5cf6', + accent: '#c4b5fd', + dark: '#4c1d95' + }, + blue: { + glow: '#3b82f6', + accent: '#93c5fd', + dark: '#1e3a8a' + }, + cyan: { + glow: '#06b6d4', + accent: '#67e8f9', + dark: '#164e63' + }, + green: { + glow: '#10b981', + accent: '#6ee7b7', + dark: '#064e3b' + }, + yellow: { + glow: '#eab308', + accent: '#fde047', + dark: '#713f12' + }, + orange: { + glow: '#f97316', + accent: '#fdba74', + dark: '#7c2d12' + }, + red: { + glow: '#ef4444', + accent: '#fca5a5', + dark: '#7f1d1d' + }, + pink: { + glow: '#ec4899', + accent: '#f9a8d4', + dark: '#831843' + } +} + +export const fetchSettings = async () => { + settingsState.isLoading = true + try { + const res = await fetch('/api/settings') + if (res.ok) { + const data = await res.json() + settingsState.themeColor = data.theme_color || 'purple' + settingsState.appTitle = data.app_title || 'NAVI.FM' + settingsState.appTagline = data.app_tagline || '' + settingsState.infoModalText = data.info_modal_text || '' + settingsState.faviconSvgOverlay = data.favicon_svg_overlay || '' + settingsState.faviconSvgColor = data.favicon_svg_color || 'white' + settingsState.artistSeparationEnabled = !!data.artist_separation_enabled + settingsState.artistSeparationCount = data.artist_separation_count || 5 + try { + settingsState.autoPilotSchedule = JSON.parse(data.auto_pilot_schedule || '[]') + } catch (e) { + settingsState.autoPilotSchedule = [] + } + } + } catch (err) { + console.error('Failed to fetch settings:', err) + } finally { + settingsState.isLoading = false + } +} + +export const updateSettings = async (token, newSettings) => { + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${token}` + }, + body: JSON.stringify({ + theme_color: newSettings.themeColor, + app_title: newSettings.appTitle, + app_tagline: newSettings.appTagline, + info_modal_text: newSettings.infoModalText, + favicon_svg_overlay: newSettings.faviconSvgOverlay, + favicon_svg_color: newSettings.faviconSvgColor, + artist_separation_enabled: newSettings.artistSeparationEnabled, + artist_separation_count: newSettings.artistSeparationCount, + auto_pilot_schedule: JSON.stringify(newSettings.autoPilotSchedule || []) + }) + }) + + if (res.ok) { + // Immediately reflect changes in frontend store + settingsState.themeColor = newSettings.themeColor + settingsState.appTitle = newSettings.appTitle + settingsState.appTagline = newSettings.appTagline + settingsState.infoModalText = newSettings.infoModalText + settingsState.faviconSvgOverlay = newSettings.faviconSvgOverlay + settingsState.faviconSvgColor = newSettings.faviconSvgColor + settingsState.artistSeparationEnabled = newSettings.artistSeparationEnabled + settingsState.artistSeparationCount = newSettings.artistSeparationCount + settingsState.autoPilotSchedule = newSettings.autoPilotSchedule + return { success: true } + } + return { success: false, status: res.status } + } catch (err) { + console.error('Failed to update settings:', err) + return { success: false, error: err.message } + } +} + +export const fetchHistory = async (token) => { + try { + const res = await fetch('/api/history', { + headers: { + 'Authorization': `Basic ${token}` + } + }) + if (res.ok) { + const data = await res.json() + settingsState.history = data.history || [] + } + } catch (err) { + console.error('Failed to fetch history:', err) + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..01e1a91 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + base: './', +}) diff --git a/htpasswd b/htpasswd new file mode 100644 index 0000000..3070936 --- /dev/null +++ b/htpasswd @@ -0,0 +1 @@ +kasbergen:$2y$05$BIpUTNbICljGVS.UZfWFHe03n/t2YkHoFb6ON06xT5H68sxgltOr6 diff --git a/icecast.xml b/icecast.xml new file mode 100644 index 0000000..2e3ded8 --- /dev/null +++ b/icecast.xml @@ -0,0 +1,50 @@ + + Earth + icemaster@localhost + + + 100 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + + hackme + hackme + admin + hackme + + + localhost + + + 8000 + + + +
+
+
+ + + + /usr/share/icecast2 + /var/log/icecast2 + /usr/share/icecast2/web + /usr/share/icecast2/admin + /var/run/icecast2/icecast.pid + + + + + access.log + error.log + 3 + 10000 + + diff --git a/liquidsoap/radio.liq b/liquidsoap/radio.liq new file mode 100644 index 0000000..9240225 --- /dev/null +++ b/liquidsoap/radio.liq @@ -0,0 +1,63 @@ +# Logging settings +settings.log.file.path := "/tmp/radio.log" +settings.log.stdout := true +settings.log.level := 2 + +# Enable Telnet server for admin controls (like skipping) +settings.server.telnet := true +settings.server.telnet.bind_addr := "0.0.0.0" +settings.server.telnet.port := 1234 + +# Fetch track dynamically from our Python API +def get_next() = + # Liquidsoap http.get returns the body as a string directly + response = http.get("http://127.0.0.1:8080/next") + url = string.trim(response) + + if url == "" then + print("WARNING: /next returned empty. Adding silence fallback.") + request.create("annotate:duration=5.0:silence") + else + print("Next track queued: #{url}") + request.create(url) + end +end + +# Define the source +radio = request.dynamic(id="navifm_queue", get_next) + +# Register a manual telnet command to skip the track since request.dynamic doesn't expose one by default +server.register("navifm_queue.skip", fun(_) -> begin source.skip(radio); "Done" end) +# Notify backend precisely when a new track starts +def on_new_track(meta) = + let title = meta["title"] + let artist = meta["artist"] + let url = "http://127.0.0.1:8080/api/internal/track_changed?title=#{url.encode(title)}&artist=#{url.encode(artist)}" + ignore(http.get(url)) +end +radio.on_track(on_new_track) + +# Precise static amplification based on LUFS (overridden by liq_amplify metadata) +radio = amplify(1., radio) + +# Smooth crossfade between tracks +radio = crossfade(smart=true, duration=4.0, fade_out=2.0, fade_in=2.0, radio) + +# Ensure the stream doesn't crash if the source is unavailable (apply right before output) +radio = mksafe(radio) + +# Retrieve icecast password from environment variable +icecast_pwd = environment.get(default="hackme", "ICECAST_PASSWORD") + +# Output stream to Icecast +output.icecast( + %mp3(bitrate=192), + host="127.0.0.1", + port=8000, + password=icecast_pwd, + mount="/stream", + name="Navi.FM", + description="Personal Smart Radio", + genre="Various", + radio +) diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..5a1a361 --- /dev/null +++ b/output.txt @@ -0,0 +1,5 @@ +spawn telnet 127.0.0.1 1234 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +Connection closed by foreign host. diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..c8db983 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,47 @@ +[supervisord] +nodaemon=true +logfile=/dev/null +logfile_maxbytes=0 + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0777 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[program:icecast] +command=icecast2 -c /etc/icecast2/icecast.xml +user=liquidsoap +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:liquidsoap] +command=liquidsoap /app/liquidsoap/radio.liq +user=liquidsoap +autostart=false +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +# Give Icecast a second to start before liquidsoap connects +startsecs=3 + +[program:fastapi] +command=/opt/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8080 +user=liquidsoap +directory=/app/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/test_liq.liq b/test_liq.liq new file mode 100644 index 0000000..b9b0d89 --- /dev/null +++ b/test_liq.liq @@ -0,0 +1,15 @@ +set("server.telnet", true) +set("server.telnet.bind_addr", "0.0.0.0") +set("server.telnet.port", 1234) + +def get_next() = + request.create("annotate:duration=5.0:silence") +end + +radio = request.dynamic(id="navifm_queue", get_next) + +# Try registering a telnet command to skip +server.register("navifm_queue.skip", fun(_) -> begin source.skip(radio); "Done" end) + +radio = mksafe(radio) +output.dummy(radio) diff --git a/test_liq.py b/test_liq.py new file mode 100644 index 0000000..338fbdc --- /dev/null +++ b/test_liq.py @@ -0,0 +1,9 @@ +import socket +with socket.create_connection(("127.0.0.1", 1234), timeout=1) as sock: + sock.sendall(b"help\r\nquit\r\n") + response = b"" + while True: + chunk = sock.recv(4096) + if not chunk: break + response += chunk + print(response.decode()) diff --git a/test_navidrome_api.py b/test_navidrome_api.py new file mode 100644 index 0000000..953ca8a --- /dev/null +++ b/test_navidrome_api.py @@ -0,0 +1,3 @@ +class MockNavidrome: + pass +# Just to check if there is a way I can test Navidrome API locally