commit 021aec7a6364cb746edaa0b587b38b783fb53935
Author: Kevin O'Neill <26398311+KevinAllen4325@users.noreply.github.com>
Date: Thu Dec 25 18:58:06 2025 -0600
Initial release v1.0.0
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5acd897
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+# Lidify Configuration
+# Copy to .env and edit as needed
+
+# ==============================================================================
+# REQUIRED: Path to your music library
+# ==============================================================================
+MUSIC_PATH=/path/to/your/music
+
+# ==============================================================================
+# OPTIONAL: Customize these if needed
+# ==============================================================================
+
+# Port to access Lidify (default: 3030)
+PORT=3030
+
+# Timezone (default: UTC)
+TZ=UTC
+
+# Session secret (auto-generated if not set)
+# Generate with: openssl rand -base64 32
+SESSION_SECRET=
+
+# DockerHub username (for pulling images)
+# Your DockerHub username (same as GitHub: chevron7locked)
+DOCKERHUB_USERNAME=chevron7locked
+
+# Version tag (use 'latest' or specific like 'v1.0.0')
+VERSION=latest
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 0000000..9133a2a
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,3 @@
+github: Chevron7Locked
+ko_fi: Chevron7Locked
+#custom: ["https://example.com/donate"]
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..fa49a82
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,113 @@
+name: Build and Publish Docker Image
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version tag (e.g., v1.0.0)"
+ required: true
+ type: string
+
+env:
+ IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/lidify
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Free up disk space
+ run: |
+ sudo rm -rf /usr/share/dotnet
+ sudo rm -rf /opt/ghc
+ sudo rm -rf /usr/local/share/boost
+ sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
+ else
+ echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: |
+ ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
+ ${{ env.IMAGE_NAME }}:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ # Note: ARM64 removed due to QEMU emulation issues with npm packages
+ # Can be re-added when using native ARM64 runners
+ platforms: linux/amd64
+
+ create-release:
+ needs: [build]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Extract version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
+ else
+ echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: ${{ steps.version.outputs.version }}
+ name: Lidify ${{ steps.version.outputs.version }}
+ body: |
+ ## Quick Start
+
+ ```bash
+ docker run -d \
+ --name lidify \
+ -p 3030:3030 \
+ -v /path/to/your/music:/music \
+ -v lidify_data:/data \
+ ${{ secrets.DOCKERHUB_USERNAME }}/lidify:${{ steps.version.outputs.version }}
+ ```
+
+ Then open http://localhost:3030 and create your account!
+
+ ## Android App
+
+ Download the APK below and install on your device.
+
+ ## Documentation
+
+ See the [README](https://github.com/${{ github.repository }}#readme) for full documentation.
+ draft: false
+ prerelease: false
+ generate_release_notes: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f4dab33
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,383 @@
+# =============================================================================
+# LIDIFY MONOREPO - .gitignore
+# =============================================================================
+
+# =============================================================================
+# Environment Variables & Secrets
+# =============================================================================
+.env
+.env.*
+!.env.example
+*.local
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# =============================================================================
+# Dependencies
+# =============================================================================
+# Node modules in all subdirectories
+**/node_modules/
+node_modules/
+jspm_packages/
+
+# Python virtual environments (for soularr, scripts)
+**/__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+venv/
+env/
+ENV/
+.venv/
+**/.venv/
+
+# =============================================================================
+# Build Outputs
+# =============================================================================
+# Frontend (Next.js)
+frontend/.next/
+frontend/out/
+frontend/build/
+frontend/dist/
+
+# Backend (Node.js/TypeScript)
+backend/dist/
+backend/build/
+backend/out/
+
+# Mobile Application
+mobile-application/build/
+mobile-application/dist/
+mobile-application/.expo/
+mobile-application/.expo-shared/
+
+# Soularr
+soularr/dist/
+soularr/build/
+
+# General build outputs
+**/dist/
+**/build/
+**/out/
+.next
+.nuxt
+
+# =============================================================================
+# Logs
+# =============================================================================
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+pnpm-debug.log*
+
+# =============================================================================
+# Testing & Coverage
+# =============================================================================
+coverage/
+*.lcov
+.nyc_output
+*.tsbuildinfo
+.cache/
+
+# =============================================================================
+# Cache Directories
+# =============================================================================
+.cache
+.parcel-cache
+.eslintcache
+.stylelintcache
+.npm
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# =============================================================================
+# Docker & Containers
+# =============================================================================
+# Don't ignore docker-compose.yml itself, but ignore local overrides
+docker-compose.override.yml
+docker-compose.local.yml
+
+# Docker volumes (if any are stored locally)
+**/volumes/
+**/data/
+
+# =============================================================================
+# IDEs & Editors
+# =============================================================================
+# VSCode
+.vscode/
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+.vscode-test
+
+# Claude Code
+.claude/
+.claude/*
+!.claude/commands/
+
+# JetBrains IDEs (WebStorm, IntelliJ, etc.)
+.idea/
+*.iml
+*.iws
+*.ipr
+
+# Sublime Text
+*.sublime-workspace
+*.sublime-project
+
+# Vim
+*.swp
+*.swo
+*~
+
+# Emacs
+*~
+\#*\#
+.\#*
+
+# macOS
+.DS_Store
+.AppleDouble
+.LSOverride
+._*
+
+# Windows
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+Desktop.ini
+$RECYCLE.BIN/
+
+# =============================================================================
+# Runtime & Process Files
+# =============================================================================
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# =============================================================================
+# Database Files (SQLite for local development)
+# =============================================================================
+*.sqlite
+*.sqlite3
+*.db
+*.db-shm
+*.db-wal
+
+# Prisma
+**/prisma/dev.db
+**/prisma/dev.db-journal
+
+# =============================================================================
+# Media & Large Files
+# =============================================================================
+# Don't commit large music files (if any test files are added)
+*.mp3
+*.flac
+*.wav
+*.m4a
+*.ogg
+*.opus
+
+# =============================================================================
+# Secrets & Key Material
+# =============================================================================
+keystore.b64
+keystore.jks
+*.keystore
+*.jks
+
+# =============================================================================
+# Runtime caches (backend)
+# =============================================================================
+backend/cache/
+backend/logs/
+backend/mullvad/
+
+# =============================================================================
+# Test artifacts
+# =============================================================================
+**/playwright-report/
+**/test-results/
+
+# Don't commit large images (unless they're assets)
+# *.jpg
+# *.jpeg
+# *.png
+# *.gif
+
+# =============================================================================
+# Temporary Files
+# =============================================================================
+*.tmp
+*.temp
+*.swp
+*.swo
+*.bak
+*.old
+
+# =============================================================================
+# Package Manager Files
+# =============================================================================
+# Yarn
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+yarn-error.log
+
+# NPM
+npm-debug.log*
+
+# PNPM
+pnpm-lock.yaml
+.pnpm-debug.log*
+
+# =============================================================================
+# Mobile Specific (React Native / Expo)
+# =============================================================================
+mobile-application/.expo/
+mobile-application/.expo-shared/
+mobile-application/android/app/build/
+mobile-application/ios/Pods/
+mobile-application/ios/build/
+mobile-application/*.jks
+mobile-application/*.keystore
+mobile-application/*.p8
+mobile-application/*.p12
+mobile-application/*.mobileprovision
+
+# Legacy native leftovers (web app is PWA-first)
+frontend/android/
+
+# =============================================================================
+# Postman (Keep collections, ignore environments with secrets)
+# =============================================================================
+postman/*environment*.json
+postman/*.local.json
+
+
+
+# BUT allow README.md files (case-insensitive)
+!README.md
+!readme.md
+!Readme.md
+
+# =============================================================================
+# TypeScript
+# =============================================================================
+*.tsbuildinfo
+tsconfig.tsbuildinfo
+
+# =============================================================================
+# Miscellaneous
+# =============================================================================
+.lock-wscript
+lib-cov
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+.grunt
+bower_components
+.serverless/
+.fusebox/
+.dynamodb/
+.tern-port
+.docusaurus
+**/.vitepress/dist
+**/.vitepress/cache
+.vuepress/dist
+.temp
+*.tgz
+.node_repl_history
+
+# =============================================================================
+# Project Specific
+# =============================================================================
+# Development scripts (keep locally, don't commit)
+reset-and-setup.sh
+organize-singles.sh
+
+# Backend development logs
+backend/logs/
+
+# Backend test cache directories
+backend/cache/test-*/
+
+# Backend duplicate/nested directories
+backend/backend/
+
+# Frontend Android build artifacts
+frontend/android/build/
+frontend/android/app/build/
+
+# Postman collections (removed from repo)
+postman/
+
+# Soularr config (removed from repo)
+soularr/
+
+# Legacy React Native files (if re-added)
+/App.tsx
+/app.json
+/src/
+
+# =============================================================================
+# IDE & Editor Settings
+# =============================================================================
+.claude/
+**/.claude/
+.cursor/
+**/.cursor/
+.vscode/
+**/.vscode/
+
+# =============================================================================
+# Android Build Artifacts (contains local paths)
+# =============================================================================
+frontend/android/app/build/
+frontend/android/build/
+frontend/android/.gradle/
+frontend/android/app/src/main/assets/capacitor.config.json
+
+# =============================================================================
+# Capacitor Generated Files
+# =============================================================================
+frontend/android/capacitor-cordova-android-plugins/build/
+
+# =============================================================================
+# Cache Files (user-specific data)
+# =============================================================================
+backend/cache/
+**/cache/covers/
+**/cache/transcodes/
+
+# =============================================================================
+# VPN / Private Configs (NEVER commit these!)
+# =============================================================================
+backend/mullvad/
+**/mullvad/
+*.conf
+**/key.txt
+
+# Android signing
+lidify.keystore
+keystore.b64
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b38c8fb
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,359 @@
+# Lidify All-in-One Docker Image (Hardened)
+# Contains: Backend, Frontend, PostgreSQL, Redis, Audio Analyzer (Essentia AI)
+# Usage: docker run -d -p 3030:3030 -v /path/to/music:/music lidify/lidify
+
+FROM node:20-slim
+
+# Add PostgreSQL 16 repository (Debian Bookworm only has PG15 by default)
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ gnupg lsb-release curl ca-certificates && \
+ echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
+ curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg && \
+ apt-get update
+
+# Install system dependencies including Python for audio analysis
+RUN apt-get install -y --no-install-recommends \
+ postgresql-16 \
+ postgresql-contrib-16 \
+ redis-server \
+ supervisor \
+ ffmpeg \
+ tini \
+ openssl \
+ bash \
+ gosu \
+ # Python for audio analyzer
+ python3 \
+ python3-pip \
+ python3-numpy \
+ # Build tools (needed for some Python packages)
+ build-essential \
+ python3-dev \
+ && rm -rf /var/lib/apt/lists/*
+
+# Create directories
+RUN mkdir -p /app/backend /app/frontend /app/audio-analyzer /app/models \
+ /data/postgres /data/redis /run/postgresql /var/log/supervisor \
+ && chown -R postgres:postgres /data/postgres /run/postgresql
+
+# ============================================
+# AUDIO ANALYZER SETUP (Essentia AI)
+# ============================================
+WORKDIR /app/audio-analyzer
+
+# Install Python dependencies for audio analysis
+RUN pip3 install --no-cache-dir --break-system-packages \
+ essentia-tensorflow \
+ redis \
+ psycopg2-binary
+
+# Download Essentia ML models (~200MB total) - these enable Enhanced vibe matching
+RUN echo "Downloading Essentia ML models for Enhanced vibe matching..." && \
+ # Base embedding model (required for all predictions)
+ curl -L --progress-bar -o /app/models/discogs-effnet-bs64-1.pb \
+ "https://essentia.upf.edu/models/feature-extractors/discogs-effnet/discogs-effnet-bs64-1.pb" && \
+ # Mood models
+ curl -L --progress-bar -o /app/models/mood_happy-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/mood_happy/mood_happy-discogs-effnet-1.pb" && \
+ curl -L --progress-bar -o /app/models/mood_sad-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/mood_sad/mood_sad-discogs-effnet-1.pb" && \
+ curl -L --progress-bar -o /app/models/mood_relaxed-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/mood_relaxed/mood_relaxed-discogs-effnet-1.pb" && \
+ curl -L --progress-bar -o /app/models/mood_aggressive-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/mood_aggressive/mood_aggressive-discogs-effnet-1.pb" && \
+ # Arousal and Valence (key for vibe matching)
+ curl -L --progress-bar -o /app/models/mood_arousal-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/mood_arousal/mood_arousal-discogs-effnet-1.pb" && \
+ curl -L --progress-bar -o /app/models/mood_valence-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/mood_valence/mood_valence-discogs-effnet-1.pb" && \
+ # Danceability and Voice/Instrumental
+ curl -L --progress-bar -o /app/models/danceability-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/danceability/danceability-discogs-effnet-1.pb" && \
+ curl -L --progress-bar -o /app/models/voice_instrumental-discogs-effnet-1.pb \
+ "https://essentia.upf.edu/models/classification-heads/voice_instrumental/voice_instrumental-discogs-effnet-1.pb" && \
+ echo "ML models downloaded successfully" && \
+ ls -lh /app/models/
+
+# Copy audio analyzer script
+COPY services/audio-analyzer/analyzer.py /app/audio-analyzer/
+
+# ============================================
+# BACKEND BUILD
+# ============================================
+WORKDIR /app/backend
+
+# Copy backend package files and install dependencies
+COPY backend/package*.json ./
+COPY backend/prisma ./prisma/
+RUN echo "=== Migrations copied ===" && ls -la prisma/migrations/ && echo "=== End migrations ==="
+RUN npm ci && npm cache clean --force
+RUN npx prisma generate
+
+# Copy backend source
+COPY backend/src ./src
+COPY backend/docker-entrypoint.sh ./
+COPY backend/healthcheck.js ./healthcheck-backend.js
+
+# Create log directory (cache will be in /data volume)
+RUN mkdir -p /app/backend/logs
+
+# ============================================
+# FRONTEND BUILD
+# ============================================
+WORKDIR /app/frontend
+
+# Copy frontend package files and install dependencies
+COPY frontend/package*.json ./
+RUN npm ci && npm cache clean --force
+
+# Copy frontend source and build
+COPY frontend/ ./
+
+# Build Next.js (production)
+ENV NEXT_PUBLIC_API_URL=
+RUN npm run build
+
+# ============================================
+# SECURITY HARDENING
+# ============================================
+# Remove dangerous tools and build dependencies AFTER all builds are complete
+# Keep: bash (supervisor), gosu (postgres user switching), python3 (audio analyzer)
+RUN apt-get purge -y --auto-remove build-essential python3-dev 2>/dev/null || true && \
+ rm -f /usr/bin/wget /bin/wget 2>/dev/null || true && \
+ rm -f /usr/bin/curl /bin/curl 2>/dev/null || true && \
+ rm -f /usr/bin/nc /bin/nc /usr/bin/ncat /usr/bin/netcat 2>/dev/null || true && \
+ rm -f /usr/bin/ftp /usr/bin/tftp /usr/bin/telnet 2>/dev/null || true && \
+ rm -rf /var/lib/apt/lists/*
+
+# ============================================
+# CONFIGURATION
+# ============================================
+WORKDIR /app
+
+# Copy healthcheck script
+COPY healthcheck-prod.js /app/healthcheck.js
+
+# Create supervisord config - logs to stdout/stderr for Docker visibility
+RUN cat > /etc/supervisor/conf.d/lidify.conf << 'EOF'
+[supervisord]
+nodaemon=true
+logfile=/dev/null
+logfile_maxbytes=0
+pidfile=/var/run/supervisord.pid
+user=root
+
+[program:postgres]
+command=/usr/lib/postgresql/16/bin/postgres -D /data/postgres
+user=postgres
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=10
+
+[program:redis]
+command=/usr/bin/redis-server --dir /data/redis --appendonly yes
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+priority=20
+
+[program:backend]
+command=/bin/bash -c "sleep 5 && cd /app/backend && npx tsx src/index.ts"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+directory=/app/backend
+priority=30
+
+[program:frontend]
+command=/bin/bash -c "sleep 10 && cd /app/frontend && npm start"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+environment=NODE_ENV="production",BACKEND_URL="http://localhost:3006",PORT="3030"
+priority=40
+
+[program:audio-analyzer]
+command=/bin/bash -c "sleep 15 && cd /app/audio-analyzer && python3 analyzer.py"
+autostart=true
+autorestart=true
+stdout_logfile=/dev/stdout
+stdout_logfile_maxbytes=0
+stderr_logfile=/dev/stderr
+stderr_logfile_maxbytes=0
+environment=DATABASE_URL="postgresql://lidify:lidify@localhost:5432/lidify",REDIS_URL="redis://localhost:6379",MUSIC_PATH="/music",BATCH_SIZE="10",SLEEP_INTERVAL="5"
+priority=50
+EOF
+
+# Fix Windows line endings in supervisor config
+RUN sed -i 's/\r$//' /etc/supervisor/conf.d/lidify.conf
+
+# Create startup script with root check
+RUN cat > /app/start.sh << 'EOF'
+#!/bin/bash
+set -e
+
+# Security check: Warn if running internal services as root
+# Note: This container runs multiple services, some require root for initial setup
+# but individual services (postgres, backend processes) run as non-root users
+
+echo ""
+echo "============================================================"
+echo " Lidify - Premium Self-Hosted Music Server"
+echo ""
+echo " Features:"
+echo " - AI-Powered Vibe Matching (Essentia ML)"
+echo " - Smart Playlists & Mood Detection"
+echo " - High-Quality Audio Streaming"
+echo ""
+echo " Security:"
+echo " - Hardened container (no wget/curl/nc)"
+echo " - Auto-generated encryption keys"
+echo "============================================================"
+echo ""
+
+# Find PostgreSQL binaries (version may vary)
+PG_BIN=$(find /usr/lib/postgresql -name "bin" -type d | head -1)
+if [ -z "$PG_BIN" ]; then
+ echo "ERROR: PostgreSQL binaries not found!"
+ exit 1
+fi
+echo "Using PostgreSQL from: $PG_BIN"
+
+# Fix permissions on data directories (may have different UID from previous container)
+echo "Fixing data directory permissions..."
+chown -R postgres:postgres /data/postgres /run/postgresql 2>/dev/null || true
+chmod 700 /data/postgres 2>/dev/null || true
+
+# Clean up stale PID file if exists
+rm -f /data/postgres/postmaster.pid 2>/dev/null || true
+
+# Initialize PostgreSQL if not already done
+if [ ! -f /data/postgres/PG_VERSION ]; then
+ echo "Initializing PostgreSQL database..."
+ gosu postgres $PG_BIN/initdb -D /data/postgres
+
+ # Configure PostgreSQL
+ echo "host all all 0.0.0.0/0 md5" >> /data/postgres/pg_hba.conf
+ echo "listen_addresses='*'" >> /data/postgres/postgresql.conf
+fi
+
+# Start PostgreSQL temporarily to create database and user
+gosu postgres $PG_BIN/pg_ctl -D /data/postgres -w start
+
+# Create user and database if they don't exist
+gosu postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname = 'lidify'" | grep -q 1 || \
+ gosu postgres psql -c "CREATE USER lidify WITH PASSWORD 'lidify';"
+gosu postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = 'lidify'" | grep -q 1 || \
+ gosu postgres psql -c "CREATE DATABASE lidify OWNER lidify;"
+
+# Run Prisma migrations
+cd /app/backend
+export DATABASE_URL="postgresql://lidify:lidify@localhost:5432/lidify"
+echo "Running Prisma migrations..."
+ls -la prisma/migrations/ || echo "No migrations directory!"
+
+# Check if _prisma_migrations table exists (indicates previous Prisma setup)
+MIGRATIONS_EXIST=$(gosu postgres psql -d lidify -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = '_prisma_migrations')" 2>/dev/null || echo "f")
+
+# Check if User table exists (indicates existing data)
+USER_TABLE_EXIST=$(gosu postgres psql -d lidify -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'User')" 2>/dev/null || echo "f")
+
+if [ "$MIGRATIONS_EXIST" = "t" ]; then
+ # Normal migration flow - migrations table exists
+ echo "Migration history found, running migrate deploy..."
+ npx prisma migrate deploy 2>&1 || {
+ echo "WARNING: Migration failed, but database preserved."
+ echo "You may need to manually resolve migration issues."
+ }
+elif [ "$USER_TABLE_EXIST" = "t" ]; then
+ # Database has data but no migrations table - needs baseline
+ echo "Existing database detected without migration history."
+ echo "Creating baseline from current schema..."
+ # Mark the init migration as already applied (baseline)
+ npx prisma migrate resolve --applied 20251130000000_init 2>&1 || true
+ # Now run any subsequent migrations
+ npx prisma migrate deploy 2>&1 || {
+ echo "WARNING: Migration after baseline failed."
+ echo "Database preserved - check migration status manually."
+ }
+else
+ # Fresh database - run migrations normally
+ echo "Fresh database detected, running initial migrations..."
+ npx prisma migrate deploy 2>&1 || {
+ echo "WARNING: Initial migration failed."
+ echo "Check database connection and schema."
+ }
+fi
+
+# Stop PostgreSQL (supervisord will start it)
+gosu postgres $PG_BIN/pg_ctl -D /data/postgres -w stop
+
+# Create persistent cache directories in /data volume
+mkdir -p /data/cache/covers /data/cache/transcodes /data/secrets
+
+# Load or generate persistent secrets
+if [ -f /data/secrets/session_secret ]; then
+ SESSION_SECRET=$(cat /data/secrets/session_secret)
+ echo "Loaded existing SESSION_SECRET"
+else
+ SESSION_SECRET=$(openssl rand -hex 32)
+ echo "$SESSION_SECRET" > /data/secrets/session_secret
+ chmod 600 /data/secrets/session_secret
+ echo "Generated and saved new SESSION_SECRET"
+fi
+
+if [ -f /data/secrets/encryption_key ]; then
+ SETTINGS_ENCRYPTION_KEY=$(cat /data/secrets/encryption_key)
+ echo "Loaded existing SETTINGS_ENCRYPTION_KEY"
+else
+ SETTINGS_ENCRYPTION_KEY=$(openssl rand -hex 32)
+ echo "$SETTINGS_ENCRYPTION_KEY" > /data/secrets/encryption_key
+ chmod 600 /data/secrets/encryption_key
+ echo "Generated and saved new SETTINGS_ENCRYPTION_KEY"
+fi
+
+# Write environment file for backend
+cat > /app/backend/.env << ENVEOF
+NODE_ENV=production
+DATABASE_URL=postgresql://lidify:lidify@localhost:5432/lidify
+REDIS_URL=redis://localhost:6379
+PORT=3006
+MUSIC_PATH=/music
+TRANSCODE_CACHE_PATH=/data/cache/transcodes
+SESSION_SECRET=$SESSION_SECRET
+SETTINGS_ENCRYPTION_KEY=$SETTINGS_ENCRYPTION_KEY
+ENVEOF
+
+echo "Starting Lidify..."
+exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
+EOF
+
+# Fix Windows line endings (CRLF -> LF) and make executable
+RUN sed -i 's/\r$//' /app/start.sh && chmod +x /app/start.sh
+
+# Expose ports
+EXPOSE 3030
+
+# Health check using Node.js (no wget)
+HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
+ CMD ["node", "/app/healthcheck.js"]
+
+# Volumes
+VOLUME ["/music", "/data"]
+
+# Use tini for proper signal handling
+ENTRYPOINT ["/usr/bin/tini", "--"]
+CMD ["/app/start.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e62ec04
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..71bedf6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,683 @@
+# Lidify
+
+[](https://hub.docker.com/r/chevron7locked/lidify)
+[](https://github.com/Chevron7Locked/lidify/releases)
+[](https://www.gnu.org/licenses/gpl-3.0)
+
+A self-hosted, on-demand audio streaming platform that brings the Spotify experience to your personal music library.
+
+Lidify is built for music lovers who want the convenience of streaming services without sacrificing ownership of their library. Point it at your music collection, and Lidify handles the rest: artist discovery, personalized playlists, podcast subscriptions, and seamless integration with tools you already use like Lidarr and Audiobookshelf.
+
+
+
+---
+
+## A Note on Native Apps
+
+I got a little ambitious trying to ship both a polished web app AND a native Android app at the same time. Turns out, trying to half-ass two things is worse than whole-assing one thing.
+
+Lidify's web app and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now.
+
+Thanks for your patience while I work through this.
+
+---
+
+## Table of Contents
+
+- [Features](#features)
+ - [The Vibe System](#the-vibe-system)
+ - [Playlist Import](#playlist-import)
+- [Mobile Support](#mobile-support)
+- [Quick Start](#quick-start)
+- [Configuration](#configuration)
+- [Integrations](#integrations)
+- [Using Lidify](#using-lidify)
+- [Administration](#administration)
+- [Architecture](#architecture)
+- [Roadmap](#roadmap)
+- [License](#license)
+- [Acknowledgments](#acknowledgments)
+
+---
+
+## Features
+
+### Your Music, Your Way
+
+- **Stream your library** - FLAC, MP3, AAC, OGG, and other common formats work out of the box
+- **Automatic cataloging** - Lidify scans your library and enriches it with metadata from MusicBrainz and Last.fm
+- **Audio transcoding** - Stream at original quality or transcode on-the-fly (320kbps, 192kbps, or 128kbps)
+
+
+
+
+
+### Discovery and Playlists
+
+- **Made For You mixes** - Programmatically generated playlists based on your library:
+ - Era mixes (Your 90s, Your 2000s, etc.)
+ - Genre mixes
+ - Top tracks
+ - Rediscover forgotten favorites
+ - Similar artist recommendations
+- **Library Radio Stations** - One-click radio modes for instant listening:
+ - Shuffle All (your entire library)
+ - Workout (high energy tracks)
+ - Discovery (lesser-played gems)
+ - Favorites (most played)
+ - Dynamic genre and decade stations generated from your library
+- **Discover Weekly** - Weekly playlists of new music tailored to your listening habits (requires Lidarr)
+- **Artist recommendations** - Find similar artists based on what you already love
+- **Deezer previews** - Preview tracks you don't own before adding them to your library
+- **Vibe matching** - Find tracks that match your current mood (see [The Vibe System](#the-vibe-system))
+
+### Podcasts
+
+- **Subscribe via RSS** - Search iTunes for podcasts and subscribe directly
+- **Track progress** - Pick up where you left off across devices
+- **Episode management** - Browse episodes, mark as played, and manage your subscriptions
+
+
+
+
+
+### Audiobooks
+
+- **Audiobookshelf integration** - Connect your existing Audiobookshelf instance
+- **Unified experience** - Browse and listen to audiobooks alongside your music
+- **Progress sync** - Your listening position syncs with Audiobookshelf
+
+
+
+
+
+### The Vibe System
+
+Lidify's standout feature for music discovery. While playing any track, activate vibe mode to find similar music in your library.
+
+- **Vibe Button** - Tap while playing any track to activate vibe mode
+- **Audio Analysis** - Real-time radar chart showing Energy, Mood, Groove, and Tempo
+- **Keep The Vibe Going** - Automatically queues tracks that match your current vibe
+- **Match Scoring** - See how well each track matches with percentage scores
+- **ML Mood Detection** - Tracks are classified across 7 moods: Happy, Sad, Relaxed, Aggressive, Party, Acoustic, Electronic
+- **Mood Mixer** - Create custom playlists by adjusting mood sliders or using presets like Workout, Chill, or Focus
+
+
+
+
+
+
+
+
+### Playlist Import
+
+Import playlists from Spotify and Deezer, or browse and discover new music directly.
+
+- **Spotify Import** - Paste any Spotify playlist URL to import tracks
+- **Deezer Import** - Same functionality for Deezer playlists
+- **Smart Preview** - See which tracks are already in your library, which albums can be downloaded, and which have no matches
+- **Selective Download** - Choose exactly which albums to add to your library
+- **Browse Deezer** - Explore Deezer's featured playlists and radio stations directly in-app
+
+
+
+
+
+
+
+
+### Multi-User Support
+
+- **Separate accounts** - Each user gets their own playlists, listening history, and preferences
+- **Admin controls** - Manage users and system settings from the web interface
+- **Two-factor authentication** - Secure accounts with TOTP-based 2FA
+
+### Custom Playlists
+
+- **Create and curate** - Build your own playlists from your library
+- **Share with others** - Make playlists public for other users on your instance
+- **Save mixes** - Convert any auto-generated mix into a permanent playlist
+
+### Mobile and TV
+
+- **Progressive Web App (PWA)** - Install Lidify on your phone or tablet for a native-like experience
+- **Android TV** - Fully optimized 10-foot interface with D-pad/remote navigation
+- **Responsive Web** - Works on any device with a modern browser
+
+
+
+
+
+
+
+---
+
+## Mobile Support
+
+### Progressive Web App (PWA)
+
+Lidify works as a PWA on mobile devices, giving you a native app-like experience without needing to download from an app store.
+
+**To install on Android:**
+
+1. Open your Lidify server in Chrome
+2. Tap the menu (⋮)
+3. Select "Add to Home Screen" or "Install app"
+
+**To install on iOS:**
+
+1. Open your Lidify server in Safari
+2. Tap the Share button
+3. Select "Add to Home Screen"
+
+**PWA Features:**
+
+- Full streaming functionality
+- Background audio playback
+- Lock screen / notification media controls (via Media Session API)
+- Offline caching for faster loads
+- Installable icon on home screen
+
+### Android TV
+
+Lidify includes a dedicated interface optimized for television displays:
+
+- Large artwork and readable text from across the room
+- Full D-pad and remote navigation support
+- Persistent Now Playing bar for quick access to playback controls
+- Simplified navigation focused on browsing and playback
+
+The TV interface is automatically enabled when accessing Lidify from an Android TV device's browser.
+
+---
+
+## Quick Start
+
+### One Command Install
+
+```bash
+docker run -d \
+ --name lidify \
+ -p 3030:3030 \
+ -v /path/to/your/music:/music \
+ -v lidify_data:/data \
+ chevron7locked/lidify:latest
+```
+
+That's it! Open http://localhost:3030 and create your account.
+
+### What's Included
+
+The Lidify container includes everything you need:
+
+- **Web Interface** (port 3030)
+- **API Server** (internal)
+- **PostgreSQL Database** (internal)
+- **Redis Cache** (internal)
+
+### Configuration Options
+
+```bash
+docker run -d \
+ --name lidify \
+ -p 3030:3030 \
+ -v /path/to/your/music:/music \
+ -v lidify_data:/data \
+ -e SESSION_SECRET=your-secret-key \
+ -e TZ=America/New_York \
+ --add-host=host.docker.internal:host-gateway \
+ chevron7locked/lidify:latest
+```
+
+| Variable | Description | Default |
+| ---------------- | ---------------------- | -------------- |
+| `SESSION_SECRET` | Session encryption key | Auto-generated |
+| `TZ` | Timezone | UTC |
+
+### Using Docker Compose
+
+Create a `docker-compose.yml` file:
+
+```yaml
+services:
+ lidify:
+ image: chevron7locked/lidify:latest
+ container_name: lidify
+ ports:
+ - "3030:3030"
+ volumes:
+ - /path/to/your/music:/music
+ - lidify_data:/data
+ environment:
+ - TZ=America/New_York
+ # Required for Lidarr webhook integration on Linux
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ restart: unless-stopped
+
+volumes:
+ lidify_data:
+```
+
+Then run:
+
+```bash
+docker compose up -d
+```
+
+**Updating with Docker Compose:**
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+---
+
+Lidify will begin scanning your music library automatically. Depending on the size of your collection, this may take a few minutes to several hours.
+
+---
+
+## Configuration
+
+### Environment Variables
+
+The unified Lidify container handles most configuration automatically. Here are the available options:
+
+| Variable | Default | Description |
+| --------------------- | ---------------------------------- | --------------------------------------------------------------------------- |
+| `SESSION_SECRET` | Auto-generated | Session encryption key (recommended to set for persistence across restarts) |
+| `TZ` | `UTC` | Timezone for the container |
+| `LIDIFY_CALLBACK_URL` | `http://host.docker.internal:3030` | URL for Lidarr webhook callbacks (see [Lidarr integration](#lidarr)) |
+
+The music library path is configured via Docker volume mount (`-v /path/to/music:/music`).
+
+#### External Access
+
+If you're accessing Lidify from outside your local network (via reverse proxy, for example), set the API URL:
+
+```env
+NEXT_PUBLIC_API_URL=https://lidify-api.yourdomain.com
+```
+
+And add your domain to the allowed origins:
+
+```env
+ALLOWED_ORIGINS=http://localhost:3030,https://lidify.yourdomain.com
+```
+
+---
+
+## Security Considerations
+
+### Environment Variables
+
+Lidify uses several sensitive environment variables. Never commit your `.env` file.
+
+| Variable | Purpose | Required |
+| ------------------------ | ------------------------------ | --------------- |
+| `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
+| `SETTINGS_ENCRYPTION_KEY`| Encrypts stored credentials | Recommended |
+| `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek |
+| `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek |
+| `LIDARR_API_KEY` | Lidarr integration | If using Lidarr |
+| `OPENAI_API_KEY` | AI features | Optional |
+| `LASTFM_API_KEY` | Artist recommendations | Optional |
+| `FANART_API_KEY` | Artist images | Optional |
+
+### VPN Configuration (Optional)
+
+If using Mullvad VPN for Soulseek:
+- Place WireGuard config in `backend/mullvad/` (gitignored)
+- Never commit VPN credentials or private keys
+- The `*.conf` and `key.txt` patterns are already in .gitignore
+
+### Generating Secrets
+
+```bash
+# Generate a secure session secret
+openssl rand -base64 32
+
+# Generate encryption key
+openssl rand -hex 32
+```
+
+### Network Security
+
+- Lidify is designed for self-hosted LAN use
+- For external access, use a reverse proxy with HTTPS
+- Configure `ALLOWED_ORIGINS` for your domain
+
+---
+
+## Integrations
+
+Lidify works beautifully on its own, but it becomes even more powerful when connected to other services.
+
+### Lidarr
+
+Connect Lidify to your Lidarr instance to request and download new music directly from the app.
+
+**What you get:**
+
+- Browse artists and albums you don't own
+- Request downloads with a single click
+- Discover Weekly playlists that automatically download new recommendations
+- Automatic library sync when Lidarr finishes importing
+
+**Setup:**
+
+1. Go to Settings in Lidify
+2. Navigate to the Lidarr section
+3. Enter your Lidarr URL (e.g., `http://localhost:8686`)
+4. Enter your Lidarr API key (found in Lidarr under Settings > General)
+5. Test the connection and save
+
+Lidify will automatically configure a webhook in Lidarr to receive notifications when new music is imported.
+
+**Networking Note:**
+
+The webhook requires Lidarr to be able to reach Lidify. By default, Lidify uses `host.docker.internal:3030` which works automatically when using the provided docker-compose files (they include `extra_hosts` to enable this on Linux).
+
+If you're using **custom Docker networks** with static IPs, set the callback URL so Lidarr knows how to reach Lidify:
+
+```yaml
+environment:
+ - LIDIFY_CALLBACK_URL=http://YOUR_LIDIFY_IP:3030
+```
+
+Use the IP address that Lidarr can reach. If both containers are on the same Docker network, use Lidify's container IP.
+
+### Audiobookshelf
+
+Connect to your Audiobookshelf instance to browse and listen to audiobooks within Lidify.
+
+**What you get:**
+
+- Browse your audiobook library
+- Stream audiobooks directly in Lidify
+- Progress syncs between Lidify and Audiobookshelf
+
+**Setup:**
+
+1. Go to Settings in Lidify
+2. Navigate to the Audiobookshelf section
+3. Enter your Audiobookshelf URL (e.g., `http://localhost:13378`)
+4. Enter your API key (found in Audiobookshelf under Settings > Users > your user > API Token)
+5. Test the connection and save
+
+### Soulseek
+
+For finding rare tracks and one-offs that aren't available through traditional sources, Lidify has built-in Soulseek support.
+
+**Setup:**
+
+1. Go to Settings in Lidify
+2. Navigate to the Soulseek section
+3. Enter your Soulseek username and password
+4. Save your settings
+
+Lidify connects directly to the Soulseek network - no additional software required.
+
+---
+
+## Using Lidify
+
+### First-Time Setup
+
+When you first access Lidify, you'll be guided through a setup wizard:
+
+1. **Create your account** - The first user becomes the administrator
+2. **Configure integrations** - Optionally connect Lidarr, Audiobookshelf, and other services
+3. **Wait for library scan** - Lidify will scan and catalog your music collection
+
+### The Home Screen
+
+After setup, your home screen displays:
+
+- **Continue Listening** - Pick up where you left off
+- **Recently Added** - New additions to your library
+- **Library Radio Stations** - One-click radio modes (Shuffle All, Workout, Discovery, Favorites, plus genre and decade stations)
+- **Made For You** - Auto-generated mixes based on your library
+- **Recommended For You** - Artist recommendations from Last.fm
+- **Popular Podcasts** - Trending podcasts you might enjoy
+- **Audiobooks** - Quick access to your audiobook library (if Audiobookshelf is connected)
+
+### Searching
+
+Lidify offers two search modes:
+
+**Library Search** - Find artists, albums, and tracks in your collection. Results are instant and searchable by name.
+
+**Discovery Search** - Find new music and podcasts you don't own. Powered by Last.fm for music and iTunes for podcasts. From discovery results, you can:
+
+- Preview tracks via Deezer
+- Request downloads through Lidarr
+- Subscribe to podcasts
+
+
+
+
+
+
+
+
+### Managing Podcasts
+
+1. Use the search bar and select "Podcasts" to find shows
+2. Click on a podcast to see its details and recent episodes
+3. Click Subscribe to add it to your library
+4. Episodes stream directly from the RSS feed - no downloads required
+
+Your listening progress is saved automatically, so you can pause on one device and resume on another.
+
+### Creating Playlists
+
+1. Navigate to your Library and select the Playlists tab
+2. Click "New Playlist" and give it a name
+3. Add tracks by clicking the menu on any song and selecting "Add to Playlist"
+4. Reorder tracks by dragging and dropping
+5. Toggle "Public" to share with other users on your instance
+
+### Using the Vibe System
+
+1. Start playing any track from your library
+2. Click the **vibe button** (waveform icon) in the player controls
+3. Lidify analyzes the track and finds matching songs based on energy, mood, and tempo
+4. Matching tracks are automatically queued - just keep listening
+5. The vibe overlay shows a radar chart comparing your current track to the source
+
+**Using the Mood Mixer:**
+
+1. Open the Mood Mixer from the home screen or player
+2. Choose a quick mood preset (Happy, Energetic, Chill, Focus, Workout) or create a custom mix
+3. Adjust sliders for happiness, energy, danceability, and tempo
+4. Lidify generates a playlist of matching tracks from your library
+
+### Importing Playlists
+
+**From Spotify:**
+
+1. Copy a Spotify playlist URL
+2. Go to Import (in the sidebar)
+3. Paste the URL and click Preview
+4. Review the results - you'll see which tracks are in your library, which can be downloaded, and which aren't available
+5. Select albums to download and start the import
+
+**From Deezer:**
+
+1. Browse featured playlists directly in the Browse section, or paste a Deezer playlist URL
+2. The same preview and import flow applies
+3. Explore Deezer's curated playlists and radio stations for discovery
+
+### Playback Settings
+
+In Settings, you can configure:
+
+- **Playback Quality** - Choose between Original, High (320kbps), Medium (192kbps), or Low (128kbps)
+- **Cache Size** - Limit how much space transcoded files use
+
+
+
+
+
+
+
+
+### Keyboard Shortcuts
+
+When using the web interface, these keyboard shortcuts are available during playback:
+
+| Key | Action |
+| ----------- | ------------------------ |
+| Space | Play / Pause |
+| N | Next track |
+| P | Previous track |
+| S | Toggle shuffle |
+| M | Toggle mute |
+| Arrow Up | Volume up |
+| Arrow Down | Volume down |
+| Arrow Right | Seek forward 10 seconds |
+| Arrow Left | Seek backward 10 seconds |
+
+### Android TV
+
+Lidify includes a dedicated interface optimized for television displays:
+
+- Large artwork and readable text from across the room
+- Full D-pad and remote navigation support
+- Persistent Now Playing bar for quick access to playback controls
+- Simplified navigation focused on browsing and playback
+
+The TV interface is automatically enabled when accessing Lidify from an Android TV device. Access it through your TV's web browser.
+
+---
+
+## Administration
+
+### Managing Users
+
+As an administrator, you can:
+
+1. Go to Settings > User Management
+2. Create new user accounts
+3. Delete existing users (except yourself)
+4. Users can be assigned "admin" or "user" roles
+
+### System Settings
+
+Administrators have access to additional settings:
+
+- **Lidarr/Audiobookshelf/Soulseek** - Configure integrations
+- **Storage Paths** - View configured paths
+- **Cache Management** - Clear caches if needed
+- **Advanced** - Download retry settings, concurrent download limits
+
+### Activity Panel
+
+The Activity Panel provides real-time visibility into downloads and system events:
+
+- **Notifications** - Alerts for completed downloads, ready playlists, and import completions
+- **Active Downloads** - Monitor download progress in real-time
+- **History** - View completed downloads and past events
+
+Access the Activity Panel by clicking the bell icon in the top bar (desktop) or through the menu (mobile).
+
+### API Keys
+
+For programmatic access to Lidify:
+
+1. Go to Settings > API Keys
+2. Generate a new key with a descriptive name
+3. Use the key in the `Authorization` header: `Bearer YOUR_API_KEY`
+
+API documentation is available at `/api-docs` when the backend is running.
+
+---
+
+## Architecture
+
+Lidify consists of several components working together:
+
+```
+ ┌─────────────────┐
+ │ Your Browser │
+ └────────┬────────┘
+ │
+ ▼
+┌─────────────────┐ ┌─────────────────────┐
+│ Music Library │◄────────────►│ Frontend │
+│ (Your Files) │ │ (Next.js :3030) │
+└─────────────────┘ └──────────┬──────────┘
+ │
+ ▼
+┌─────────────────┐ ┌─────────────────────┐
+│ Lidarr │◄────────────►│ Backend │
+│ (Optional) │ │ (Express.js :3006) │
+└─────────────────┘ └──────────┬──────────┘
+ │
+┌─────────────────┐ ┌──────────┴──────────┐
+│ Audiobookshelf │◄────────────►│ │
+│ (Optional) │ │ ┌───────────────┐ │
+└─────────────────┘ │ │ PostgreSQL │ │
+ │ └───────────────┘ │
+ │ ┌───────────────┐ │
+ │ │ Redis │ │
+ │ └───────────────┘ │
+ └─────────────────────┘
+```
+
+| Component | Purpose | Default Port |
+| ---------- | ----------------------- | ------------ |
+| Frontend | Web interface (Next.js) | 3030 |
+| Backend | API server (Express.js) | 3006 |
+| PostgreSQL | Database | 5432 |
+| Redis | Caching and job queues | 6379 |
+
+---
+
+## Roadmap
+
+Lidify is under active development. Here's what's planned:
+
+- **Native Mobile App** - React Native application for iOS and Android
+- **Offline Mode** - Download tracks for offline playback
+- **Windows Executable** - Standalone app for Windows users who prefer not to use Docker
+
+Contributions and suggestions are welcome.
+
+---
+
+## License
+
+Lidify is released under the [GNU General Public License v3.0](LICENSE).
+
+You are free to use, modify, and distribute this software under the terms of the GPL-3.0 license.
+
+---
+
+## Acknowledgments
+
+Lidify wouldn't be possible without these services and projects:
+
+- [Last.fm](https://www.last.fm/) - Artist recommendations and music metadata
+- [MusicBrainz](https://musicbrainz.org/) - Comprehensive music database
+- [iTunes Search API](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/) - Podcast discovery
+- [Deezer](https://developers.deezer.com/) - Track previews
+- [Fanart.tv](https://fanart.tv/) - Artist images and artwork
+- [Lidarr](https://lidarr.audio/) - Music collection management
+- [Audiobookshelf](https://www.audiobookshelf.org/) - Audiobook and podcast server
+
+---
+
+## Support
+
+If you encounter issues or have questions:
+
+1. Check the [Issues](https://github.com/chevron7locked/lidify/issues) page for known problems
+2. Open a new issue with details about your setup and the problem you're experiencing
+3. Include logs from `docker compose logs` if relevant
+
+---
+
+_Built with love for the self-hosted community._
diff --git a/assets/screenshots/deezer-browse.png b/assets/screenshots/deezer-browse.png
new file mode 100644
index 0000000..9babab4
Binary files /dev/null and b/assets/screenshots/deezer-browse.png differ
diff --git a/assets/screenshots/desktop-album.png b/assets/screenshots/desktop-album.png
new file mode 100644
index 0000000..3ec044e
Binary files /dev/null and b/assets/screenshots/desktop-album.png differ
diff --git a/assets/screenshots/desktop-artist.png b/assets/screenshots/desktop-artist.png
new file mode 100644
index 0000000..f45eb10
Binary files /dev/null and b/assets/screenshots/desktop-artist.png differ
diff --git a/assets/screenshots/desktop-audiobooks.png b/assets/screenshots/desktop-audiobooks.png
new file mode 100644
index 0000000..5fcb838
Binary files /dev/null and b/assets/screenshots/desktop-audiobooks.png differ
diff --git a/assets/screenshots/desktop-home.png b/assets/screenshots/desktop-home.png
new file mode 100644
index 0000000..72bbd80
Binary files /dev/null and b/assets/screenshots/desktop-home.png differ
diff --git a/assets/screenshots/desktop-library.png b/assets/screenshots/desktop-library.png
new file mode 100644
index 0000000..3145ced
Binary files /dev/null and b/assets/screenshots/desktop-library.png differ
diff --git a/assets/screenshots/desktop-player.png b/assets/screenshots/desktop-player.png
new file mode 100644
index 0000000..25be9d3
Binary files /dev/null and b/assets/screenshots/desktop-player.png differ
diff --git a/assets/screenshots/desktop-podcasts.png b/assets/screenshots/desktop-podcasts.png
new file mode 100644
index 0000000..7b9b774
Binary files /dev/null and b/assets/screenshots/desktop-podcasts.png differ
diff --git a/assets/screenshots/desktop-settings.png b/assets/screenshots/desktop-settings.png
new file mode 100644
index 0000000..12e9ff4
Binary files /dev/null and b/assets/screenshots/desktop-settings.png differ
diff --git a/assets/screenshots/mobile-album.png b/assets/screenshots/mobile-album.png
new file mode 100644
index 0000000..85b78a2
Binary files /dev/null and b/assets/screenshots/mobile-album.png differ
diff --git a/assets/screenshots/mobile-artist.png b/assets/screenshots/mobile-artist.png
new file mode 100644
index 0000000..7edaac2
Binary files /dev/null and b/assets/screenshots/mobile-artist.png differ
diff --git a/assets/screenshots/mobile-audiobooks.png b/assets/screenshots/mobile-audiobooks.png
new file mode 100644
index 0000000..f886336
Binary files /dev/null and b/assets/screenshots/mobile-audiobooks.png differ
diff --git a/assets/screenshots/mobile-home.png b/assets/screenshots/mobile-home.png
new file mode 100644
index 0000000..7b72fd6
Binary files /dev/null and b/assets/screenshots/mobile-home.png differ
diff --git a/assets/screenshots/mobile-library.png b/assets/screenshots/mobile-library.png
new file mode 100644
index 0000000..bfca73b
Binary files /dev/null and b/assets/screenshots/mobile-library.png differ
diff --git a/assets/screenshots/mobile-login.png b/assets/screenshots/mobile-login.png
new file mode 100644
index 0000000..d42e647
Binary files /dev/null and b/assets/screenshots/mobile-login.png differ
diff --git a/assets/screenshots/mobile-player.png b/assets/screenshots/mobile-player.png
new file mode 100644
index 0000000..c9b4e49
Binary files /dev/null and b/assets/screenshots/mobile-player.png differ
diff --git a/assets/screenshots/mobile-podcasts.png b/assets/screenshots/mobile-podcasts.png
new file mode 100644
index 0000000..ca99951
Binary files /dev/null and b/assets/screenshots/mobile-podcasts.png differ
diff --git a/assets/screenshots/mood-mixer.png b/assets/screenshots/mood-mixer.png
new file mode 100644
index 0000000..15fadcb
Binary files /dev/null and b/assets/screenshots/mood-mixer.png differ
diff --git a/assets/screenshots/reddit post/desktop-home.png b/assets/screenshots/reddit post/desktop-home.png
new file mode 100644
index 0000000..72bbd80
Binary files /dev/null and b/assets/screenshots/reddit post/desktop-home.png differ
diff --git a/assets/screenshots/reddit post/desktop-podcasts.png b/assets/screenshots/reddit post/desktop-podcasts.png
new file mode 100644
index 0000000..7b9b774
Binary files /dev/null and b/assets/screenshots/reddit post/desktop-podcasts.png differ
diff --git a/assets/screenshots/reddit post/mobile-home.png b/assets/screenshots/reddit post/mobile-home.png
new file mode 100644
index 0000000..7b72fd6
Binary files /dev/null and b/assets/screenshots/reddit post/mobile-home.png differ
diff --git a/assets/screenshots/reddit post/mobile-player.png b/assets/screenshots/reddit post/mobile-player.png
new file mode 100644
index 0000000..c9b4e49
Binary files /dev/null and b/assets/screenshots/reddit post/mobile-player.png differ
diff --git a/assets/screenshots/reddit post/vibe-overlay.png b/assets/screenshots/reddit post/vibe-overlay.png
new file mode 100644
index 0000000..7b265d5
Binary files /dev/null and b/assets/screenshots/reddit post/vibe-overlay.png differ
diff --git a/assets/screenshots/spotify-import-preview.png b/assets/screenshots/spotify-import-preview.png
new file mode 100644
index 0000000..cf4d075
Binary files /dev/null and b/assets/screenshots/spotify-import-preview.png differ
diff --git a/assets/screenshots/vibe-overlay.png b/assets/screenshots/vibe-overlay.png
new file mode 100644
index 0000000..7b265d5
Binary files /dev/null and b/assets/screenshots/vibe-overlay.png differ
diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..d176252
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,43 @@
+# Dependencies
+node_modules
+npm-debug.log
+yarn-error.log
+
+# Build output
+dist
+build
+*.tsbuildinfo
+
+# Environment files
+.env
+.env.local
+.env.*.local
+
+# Testing
+coverage
+*.test.ts
+**/__tests__
+
+# Development
+.vscode
+.idea
+*.swp
+*.swo
+*~
+
+# Cache and logs
+cache
+logs
+*.log
+
+# Git
+.git
+.gitignore
+
+# Documentation
+*.md
+docs
+
+# Misc
+.DS_Store
+Thumbs.db
diff --git a/backend/.gitignore b/backend/.gitignore
new file mode 100644
index 0000000..73b2c90
--- /dev/null
+++ b/backend/.gitignore
@@ -0,0 +1,21 @@
+node_modules/
+dist/
+.env
+.env.*
+.DS_Store
+logs/
+*.log
+
+# Runtime caches (safe to delete; regenerated)
+cache/
+
+# VPN configs for local testing (do not commit)
+mullvad/
+
+# Stray media artifacts (should never be committed)
+*.mp3
+*.flac
+*.wav
+*.m4a
+*.ogg
+*.opus
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..f12b256
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,65 @@
+# Stage 1: Dependencies (all deps for tsx runtime)
+FROM node:20-slim AS deps
+
+WORKDIR /app
+
+# Copy package files
+COPY package*.json ./
+COPY prisma ./prisma/
+
+# Install ALL dependencies (tsx needs dev dependencies)
+RUN npm ci && \
+ npm cache clean --force
+
+# Generate Prisma Client
+RUN npx prisma generate
+
+# Stage 2: Production runtime (Hardened)
+FROM node:20-slim
+
+WORKDIR /app
+
+# Install runtime dependencies first
+# ffmpeg is required for audio transcoding
+# openssl is required for Prisma
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ffmpeg \
+ tini \
+ openssl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Copy all node_modules (including tsx)
+COPY --from=deps /app/node_modules ./node_modules
+COPY --from=deps /app/package*.json ./
+COPY --from=deps /app/prisma ./prisma
+
+# Copy source code (will run with tsx, not compiled)
+COPY src ./src
+
+# Copy healthcheck script and shell entrypoint
+COPY healthcheck.js ./
+COPY docker-entrypoint.sh /usr/local/bin/
+
+# Create directories, fix line endings, set permissions, then remove dangerous tools
+# NOTE: We keep /bin/sh because npm/npx require it to spawn processes
+RUN mkdir -p /app/cache/covers /app/cache/transcodes /app/logs && \
+ sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
+ chmod +x /usr/local/bin/docker-entrypoint.sh && \
+ chown -R node:node /app && \
+ # Remove download/network utilities (prevents downloading malware)
+ rm -f /usr/bin/wget /usr/bin/curl /bin/wget /bin/curl 2>/dev/null || true && \
+ rm -f /usr/bin/nc /bin/nc /usr/bin/ncat /usr/bin/netcat 2>/dev/null || true && \
+ rm -f /usr/bin/ftp /usr/bin/tftp /usr/bin/telnet 2>/dev/null || true
+
+# Use non-root user
+USER node
+
+EXPOSE 3006
+
+# Health check using Node.js (no wget needed)
+HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
+ CMD ["node", "healthcheck.js"]
+
+# Use tini for proper signal handling
+ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"]
+CMD ["npx", "tsx", "src/index.ts"]
diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh
new file mode 100644
index 0000000..bca58fb
--- /dev/null
+++ b/backend/docker-entrypoint.sh
@@ -0,0 +1,59 @@
+#!/bin/sh
+set -e
+
+# Security check: Refuse to run as root
+if [ "$(id -u)" = "0" ]; then
+ echo ""
+ echo "╔══════════════════════════════════════════════════════════════╗"
+ echo "║ FATAL: CANNOT START AS ROOT ║"
+ echo "║ ║"
+ echo "║ Running as root is a security risk. This container must ║"
+ echo "║ run as a non-privileged user. ║"
+ echo "║ ║"
+ echo "║ Do NOT use: ║"
+ echo "║ - docker run --user root ║"
+ echo "║ - user: root in docker-compose.yml ║"
+ echo "║ ║"
+ echo "║ The container is configured to run as 'node' user. ║"
+ echo "╚══════════════════════════════════════════════════════════════╝"
+ echo ""
+ exit 1
+fi
+
+echo "[START] Starting Lidify Backend..."
+
+# Docker Compose health checks ensure database and Redis are ready
+# Add a small delay to be extra safe
+echo "[WAIT] Waiting for services to be ready..."
+sleep 3
+echo "Services are ready"
+
+# Run database migrations
+echo "[DB] Running database migrations..."
+npx prisma migrate deploy
+
+# Generate Prisma client (in case of schema changes)
+echo "[DB] Generating Prisma client..."
+npx prisma generate
+
+# Generate session secret if not provided
+if [ -z "$SESSION_SECRET" ] || [ "$SESSION_SECRET" = "changeme-generate-secure-key" ]; then
+ echo "[WARN] SESSION_SECRET not set or using default. Generating random key..."
+ export SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")
+ echo "Generated SESSION_SECRET (will not persist across restarts - set it in .env for production)"
+fi
+
+# Ensure encryption key is stable between restarts
+if [ -z "$SETTINGS_ENCRYPTION_KEY" ]; then
+ echo "[WARN] SETTINGS_ENCRYPTION_KEY not set."
+ echo " Falling back to the default development key so encrypted data remains readable."
+ echo " Set SETTINGS_ENCRYPTION_KEY in your environment to a 32-character value for production."
+ export SETTINGS_ENCRYPTION_KEY="default-encryption-key-change-me"
+fi
+
+echo "[START] Lidify Backend starting on port ${PORT:-3006}..."
+echo "[CONFIG] Music path: ${MUSIC_PATH:-/music}"
+echo "[CONFIG] Environment: ${NODE_ENV:-production}"
+
+# Execute the main command
+exec "$@"
diff --git a/backend/healthcheck.js b/backend/healthcheck.js
new file mode 100644
index 0000000..2fa64b6
--- /dev/null
+++ b/backend/healthcheck.js
@@ -0,0 +1,24 @@
+// Minimal health check script - no external dependencies
+const http = require('http');
+
+const options = {
+ hostname: 'localhost',
+ port: 3006,
+ path: '/health',
+ method: 'GET',
+ timeout: 5000,
+};
+
+const req = http.request(options, (res) => {
+ process.exit(res.statusCode >= 200 && res.statusCode < 400 ? 0 : 1);
+});
+
+req.on('error', () => process.exit(1));
+req.on('timeout', () => {
+ req.destroy();
+ process.exit(1);
+});
+
+req.end();
+
+
diff --git a/backend/package-lock.json b/backend/package-lock.json
new file mode 100644
index 0000000..c22d6c7
--- /dev/null
+++ b/backend/package-lock.json
@@ -0,0 +1,5578 @@
+{
+ "name": "lidify-backend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lidify-backend",
+ "version": "1.0.0",
+ "license": "GPL-3.0",
+ "dependencies": {
+ "@bull-board/api": "^6.14.2",
+ "@bull-board/express": "^6.14.2",
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
+ "@prisma/client": "^5.22.0",
+ "@types/bull": "^3.15.9",
+ "@types/fluent-ffmpeg": "^2.1.28",
+ "@types/node-cron": "^3.0.11",
+ "@types/qrcode": "^1.5.6",
+ "@types/speakeasy": "^2.0.10",
+ "@types/swagger-jsdoc": "^6.0.4",
+ "@types/swagger-ui-express": "^4.1.8",
+ "axios": "^1.6.2",
+ "bcrypt": "^5.1.1",
+ "bull": "^4.16.5",
+ "connect-redis": "^7.1.0",
+ "cors": "^2.8.5",
+ "date-fns": "^4.1.0",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-rate-limit": "^8.2.1",
+ "express-session": "^1.17.3",
+ "ffmpeg-static": "^5.2.0",
+ "fluent-ffmpeg": "^2.1.3",
+ "fuzzball": "^2.2.3",
+ "helmet": "^7.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "music-metadata": "^11.10.0",
+ "node-cron": "^4.2.1",
+ "p-queue": "^9.0.0",
+ "podcast-index-api": "^1.1.10",
+ "qrcode": "^1.5.4",
+ "redis": "^4.6.10",
+ "rss-parser": "^3.13.0",
+ "sharp": "^0.34.5",
+ "slsk-client": "^1.1.0",
+ "speakeasy": "^2.0.0",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@types/bcrypt": "^5.0.2",
+ "@types/express": "^4.17.21",
+ "@types/express-session": "^1.17.10",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/node": "^20.10.4",
+ "prisma": "^5.22.0",
+ "tsx": "^4.7.0",
+ "typescript": "^5.3.3"
+ }
+ },
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz",
+ "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==",
+ "license": "MIT",
+ "dependencies": {
+ "@jsdevtools/ono": "^7.1.3",
+ "@types/json-schema": "^7.0.6",
+ "call-me-maybe": "^1.0.1",
+ "js-yaml": "^4.1.0"
+ }
+ },
+ "node_modules/@apidevtools/openapi-schemas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
+ "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@apidevtools/swagger-methods": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
+ "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
+ "license": "MIT"
+ },
+ "node_modules/@apidevtools/swagger-parser": {
+ "version": "10.0.3",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz",
+ "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==",
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "^9.0.6",
+ "@apidevtools/openapi-schemas": "^2.0.4",
+ "@apidevtools/swagger-methods": "^3.0.2",
+ "@jsdevtools/ono": "^7.1.3",
+ "call-me-maybe": "^1.0.1",
+ "z-schema": "^5.0.1"
+ },
+ "peerDependencies": {
+ "openapi-types": ">=7"
+ }
+ },
+ "node_modules/@borewit/text-codec": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.0.tgz",
+ "integrity": "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@bull-board/api": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.15.0.tgz",
+ "integrity": "sha512-z8qLZ4uv83hZNu+0YnHzhVoWv1grULuYh80FdC2xXLg8M1EwsOZD9cJ5CNpgBFqHb+NVByTmf5FltIvXdOU8tQ==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-info": "^3.1.0"
+ },
+ "peerDependencies": {
+ "@bull-board/ui": "6.15.0"
+ }
+ },
+ "node_modules/@bull-board/express": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.15.0.tgz",
+ "integrity": "sha512-c/nnxr5evLNgqoSSEvTwPb+6WaTB3PN3Bq2oMTBtwCUJlZr+s1UX7gx0wVIYHjeZyUdYR7fX7hhh2cRLO5vqeg==",
+ "license": "MIT",
+ "dependencies": {
+ "@bull-board/api": "6.15.0",
+ "@bull-board/ui": "6.15.0",
+ "ejs": "^3.1.10",
+ "express": "^5.2.0"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/body-parser": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
+ "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/content-disposition": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
+ "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/iconv-lite": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz",
+ "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/@bull-board/express/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/@bull-board/express/node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@bull-board/ui": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.15.0.tgz",
+ "integrity": "sha512-bb/j6VMq2cfPoE/ZiUO7AcYTL0IjtxvKxkYV0zu+i1pc+JEv3ct4BItCII57knJR/YjZKGmdfr079KJFvzXC5A==",
+ "license": "MIT",
+ "dependencies": {
+ "@bull-board/api": "6.15.0"
+ }
+ },
+ "node_modules/@derhuerst/http-basic": {
+ "version": "8.2.4",
+ "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
+ "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
+ "license": "MIT",
+ "dependencies": {
+ "caseless": "^0.12.0",
+ "concat-stream": "^2.0.0",
+ "http-response-object": "^3.0.1",
+ "parse-cache-control": "^1.0.1"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
+ "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@ffmpeg-installer/darwin-arm64": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz",
+ "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==",
+ "cpu": [
+ "arm64"
+ ],
+ "hasInstallScript": true,
+ "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/darwin-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz",
+ "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==",
+ "cpu": [
+ "x64"
+ ],
+ "hasInstallScript": true,
+ "license": "LGPL-2.1",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/ffmpeg": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz",
+ "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==",
+ "license": "LGPL-2.1",
+ "optionalDependencies": {
+ "@ffmpeg-installer/darwin-arm64": "4.1.5",
+ "@ffmpeg-installer/darwin-x64": "4.1.0",
+ "@ffmpeg-installer/linux-arm": "4.1.3",
+ "@ffmpeg-installer/linux-arm64": "4.1.4",
+ "@ffmpeg-installer/linux-ia32": "4.1.0",
+ "@ffmpeg-installer/linux-x64": "4.1.0",
+ "@ffmpeg-installer/win32-ia32": "4.1.0",
+ "@ffmpeg-installer/win32-x64": "4.1.0"
+ }
+ },
+ "node_modules/@ffmpeg-installer/linux-arm": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz",
+ "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==",
+ "cpu": [
+ "arm"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-arm64": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz",
+ "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-ia32": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz",
+ "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/linux-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz",
+ "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==",
+ "cpu": [
+ "x64"
+ ],
+ "hasInstallScript": true,
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/win32-ia32": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz",
+ "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@ffmpeg-installer/win32-x64": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz",
+ "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "GPLv3",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@ioredis/commands": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
+ "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@jsdevtools/ono": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
+ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
+ "license": "MIT"
+ },
+ "node_modules/@mapbox/node-pre-gyp": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
+ "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "https-proxy-agent": "^5.0.0",
+ "make-dir": "^3.1.0",
+ "node-fetch": "^2.6.7",
+ "nopt": "^5.0.0",
+ "npmlog": "^5.0.1",
+ "rimraf": "^3.0.2",
+ "semver": "^7.3.5",
+ "tar": "^6.1.11"
+ },
+ "bin": {
+ "node-pre-gyp": "bin/node-pre-gyp"
+ }
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
+ "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
+ "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
+ "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
+ "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
+ "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
+ "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@prisma/client": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
+ "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.13"
+ },
+ "peerDependencies": {
+ "prisma": "*"
+ },
+ "peerDependenciesMeta": {
+ "prisma": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@prisma/debug": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
+ "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/engines": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
+ "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/fetch-engine": "5.22.0",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/engines-version": {
+ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
+ "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@prisma/fetch-engine": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
+ "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0",
+ "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
+ "@prisma/get-platform": "5.22.0"
+ }
+ },
+ "node_modules/@prisma/get-platform": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
+ "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/debug": "5.22.0"
+ }
+ },
+ "node_modules/@redis/bloom": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
+ "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
+ "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "cluster-key-slot": "1.1.2",
+ "generic-pool": "3.9.0",
+ "yallist": "4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@redis/graph": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
+ "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
+ "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
+ "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
+ "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@scarf/scarf": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
+ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
+ "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@szmarczak/http-timer": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+ "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+ "license": "MIT",
+ "dependencies": {
+ "defer-to-connect": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@tokenizer/inflate": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
+ "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "token-types": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@tokenizer/inflate/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@tokenizer/inflate/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/bcrypt": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
+ "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/body-parser": {
+ "version": "1.19.6",
+ "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+ "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/connect": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/bull": {
+ "version": "3.15.9",
+ "resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.15.9.tgz",
+ "integrity": "sha512-MPUcyPPQauAmynoO3ezHAmCOhbB0pWmYyijr/5ctaCqhbKWsjW0YCod38ZcLzUBprosfZ9dPqfYIcfdKjk7RNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/ioredis": "*",
+ "@types/redis": "^2.8.0"
+ }
+ },
+ "node_modules/@types/cacheable-request": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
+ "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-cache-semantics": "*",
+ "@types/keyv": "^3.1.4",
+ "@types/node": "*",
+ "@types/responselike": "^1.0.0"
+ }
+ },
+ "node_modules/@types/connect": {
+ "version": "3.4.38",
+ "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+ "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/express": {
+ "version": "4.17.25",
+ "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
+ "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/body-parser": "*",
+ "@types/express-serve-static-core": "^4.17.33",
+ "@types/qs": "*",
+ "@types/serve-static": "^1"
+ }
+ },
+ "node_modules/@types/express-serve-static-core": {
+ "version": "4.19.7",
+ "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
+ "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/qs": "*",
+ "@types/range-parser": "*",
+ "@types/send": "*"
+ }
+ },
+ "node_modules/@types/express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
+ "node_modules/@types/fluent-ffmpeg": {
+ "version": "2.1.28",
+ "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz",
+ "integrity": "sha512-5ovxsDwBcPfJ+eYs1I/ZpcYCnkce7pvH9AHSvrZllAp1ZPpTRDZAFjF3TRFbukxSgIYTTNYePbS0rKUmaxVbXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/http-cache-semantics": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
+ "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/http-errors": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ioredis": {
+ "version": "4.28.10",
+ "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz",
+ "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/jsonwebtoken": {
+ "version": "9.0.10",
+ "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
+ "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/ms": "*",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/keyv": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
+ "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/mime": {
+ "version": "1.3.5",
+ "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
+ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ms": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.27",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz",
+ "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/node-cron": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
+ "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/range-parser": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/redis": {
+ "version": "2.8.32",
+ "resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
+ "integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/responselike": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
+ "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/serve-static": {
+ "version": "1.15.10",
+ "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
+ "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-errors": "*",
+ "@types/node": "*",
+ "@types/send": "<1"
+ }
+ },
+ "node_modules/@types/serve-static/node_modules/@types/send": {
+ "version": "0.17.6",
+ "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
+ "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/mime": "^1",
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/speakeasy": {
+ "version": "2.0.10",
+ "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz",
+ "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/swagger-jsdoc": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz",
+ "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/swagger-ui-express": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz",
+ "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*",
+ "@types/serve-static": "*"
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
+ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
+ "license": "ISC"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/agent-base/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/agent-base/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aproba": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
+ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
+ "license": "ISC"
+ },
+ "node_modules/are-we-there-yet": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
+ "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "delegates": "^1.0.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/async": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz",
+ "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
+ "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "license": "MIT"
+ },
+ "node_modules/base32.js": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
+ "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
+ "license": "MIT"
+ },
+ "node_modules/bcrypt": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
+ "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.11",
+ "node-addon-api": "^5.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.4",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
+ "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.14.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/bull": {
+ "version": "4.16.5",
+ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
+ "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cron-parser": "^4.9.0",
+ "get-port": "^5.1.1",
+ "ioredis": "^5.3.2",
+ "lodash": "^4.17.21",
+ "msgpackr": "^1.11.2",
+ "semver": "^7.5.2",
+ "uuid": "^8.3.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/cacheable-lookup": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+ "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.6.0"
+ }
+ },
+ "node_modules/cacheable-request": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
+ "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
+ "license": "MIT",
+ "dependencies": {
+ "clone-response": "^1.0.2",
+ "get-stream": "^5.1.0",
+ "http-cache-semantics": "^4.0.0",
+ "keyv": "^4.0.0",
+ "lowercase-keys": "^2.0.0",
+ "normalize-url": "^6.0.1",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-me-maybe": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
+ "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
+ "license": "MIT"
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caseless": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/chownr": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
+ "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/clone-response": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
+ "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-support": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
+ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
+ "license": "ISC",
+ "bin": {
+ "color-support": "bin.js"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
+ "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "license": "MIT"
+ },
+ "node_modules/concat-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
+ "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
+ "engines": [
+ "node >= 6.0"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.0.2",
+ "typedarray": "^0.0.6"
+ }
+ },
+ "node_modules/connect-redis": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-7.1.1.tgz",
+ "integrity": "sha512-M+z7alnCJiuzKa8/1qAYdGUXHYfDnLolOGAUjOioB07pP39qxjG+X9ibsud7qUBc4jMV5Mcy3ugGv8eFcgamJQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "express-session": ">=1"
+ }
+ },
+ "node_modules/console-control-strings": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
+ "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
+ "license": "ISC"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/cron-parser": {
+ "version": "4.9.0",
+ "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
+ "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "luxon": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/decompress-response/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/defer-to-connect": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+ "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/delegates": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
+ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
+ "license": "MIT"
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "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==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "license": "BSD-2-Clause",
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
+ "node_modules/express": {
+ "version": "4.22.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+ "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.3",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.14.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
+ "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "10.0.1"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/express-session": {
+ "version": "1.18.2",
+ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz",
+ "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.7",
+ "debug": "2.6.9",
+ "depd": "~2.0.0",
+ "on-headers": "~1.1.0",
+ "parseurl": "~1.3.3",
+ "safe-buffer": "5.2.1",
+ "uid-safe": "~2.1.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/ffmpeg-static": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
+ "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
+ "hasInstallScript": true,
+ "license": "GPL-3.0-or-later",
+ "dependencies": {
+ "@derhuerst/http-basic": "^8.2.0",
+ "env-paths": "^2.2.0",
+ "https-proxy-agent": "^5.0.0",
+ "progress": "^2.0.3"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/file-type": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz",
+ "integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/inflate": "^0.4.1",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.1",
+ "uint8array-extras": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
+ "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fluent-ffmpeg": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz",
+ "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==",
+ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
+ "license": "MIT",
+ "dependencies": {
+ "async": "^0.2.9",
+ "which": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fs-minipass": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
+ "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/fs-minipass/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "license": "ISC"
+ },
+ "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/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/fuzzball": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/fuzzball/-/fuzzball-2.2.3.tgz",
+ "integrity": "sha512-sQDb3kjI7auA4YyE1YgEW85MTparcSgRgcCweUK06Cn0niY5lN+uhFiRUZKN4MQVGGiHxlbrYCA4nL1QjOXBLQ==",
+ "license": "MIT",
+ "dependencies": {
+ "heap": ">=0.2.0",
+ "lodash": "^4.17.21",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/gauge": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
+ "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "aproba": "^1.0.3 || ^2.0.0",
+ "color-support": "^1.1.2",
+ "console-control-strings": "^1.0.0",
+ "has-unicode": "^2.0.1",
+ "object-assign": "^4.1.1",
+ "signal-exit": "^3.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1",
+ "wide-align": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/generic-pool": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+ "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-port": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
+ "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-tsconfig": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
+ "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/glob/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/glob/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/got": {
+ "version": "11.8.6",
+ "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
+ "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@sindresorhus/is": "^4.0.0",
+ "@szmarczak/http-timer": "^4.0.5",
+ "@types/cacheable-request": "^6.0.1",
+ "@types/responselike": "^1.0.0",
+ "cacheable-lookup": "^5.0.3",
+ "cacheable-request": "^7.0.2",
+ "decompress-response": "^6.0.0",
+ "http2-wrapper": "^1.0.0-beta.5.2",
+ "lowercase-keys": "^2.0.0",
+ "p-cancelable": "^2.0.0",
+ "responselike": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/got?sponsor=1"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-unicode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
+ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/heap": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz",
+ "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==",
+ "license": "MIT"
+ },
+ "node_modules/helmet": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
+ "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/http-cache-semantics": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
+ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/http-response-object": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
+ "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^10.0.3"
+ }
+ },
+ "node_modules/http-response-object/node_modules/@types/node": {
+ "version": "10.17.60",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+ "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
+ "license": "MIT"
+ },
+ "node_modules/http2-wrapper": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+ "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+ "license": "MIT",
+ "dependencies": {
+ "quick-lru": "^5.1.1",
+ "resolve-alpn": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=10.19.0"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/https-proxy-agent/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ioredis": {
+ "version": "5.8.2",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
+ "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@ioredis/commands": "1.4.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
+ "node_modules/ioredis/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ioredis/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ip-address": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
+ "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/jake/node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.get": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+ "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==",
+ "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.mergewith": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
+ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/lowercase-keys": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+ "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/luxon": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
+ "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+ "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-response": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
+ "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
+ "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
+ "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
+ "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^3.0.0",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/minizlib/node_modules/minipass": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
+ "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/mkdirp": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
+ "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
+ "license": "MIT",
+ "bin": {
+ "mkdirp": "bin/cmd.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/msgpackr": {
+ "version": "1.11.8",
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.8.tgz",
+ "integrity": "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "msgpackr-extract": "^3.0.2"
+ }
+ },
+ "node_modules/msgpackr-extract": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
+ "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "node-gyp-build-optional-packages": "5.2.2"
+ },
+ "bin": {
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
+ },
+ "optionalDependencies": {
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
+ }
+ },
+ "node_modules/music-metadata": {
+ "version": "11.10.3",
+ "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz",
+ "integrity": "sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://buymeacoffee.com/borewit"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.0",
+ "@tokenizer/token": "^0.3.0",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "file-type": "^21.1.1",
+ "media-typer": "^1.1.0",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.1",
+ "uint8array-extras": "^1.5.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/music-metadata/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/music-metadata/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
+ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
+ "license": "MIT"
+ },
+ "node_modules/node-cron": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
+ "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/node-gyp-build-optional-packages": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^2.0.1"
+ },
+ "bin": {
+ "node-gyp-build-optional-packages": "bin.js",
+ "node-gyp-build-optional-packages-optional": "optional.js",
+ "node-gyp-build-optional-packages-test": "build-test.js"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+ "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "1"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/normalize-url": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+ "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npmlog": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
+ "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
+ "deprecated": "This package is no longer supported.",
+ "license": "ISC",
+ "dependencies": {
+ "are-we-there-yet": "^2.0.0",
+ "console-control-strings": "^1.1.0",
+ "gauge": "^3.0.0",
+ "set-blocking": "^2.0.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/openapi-types": {
+ "version": "12.1.3",
+ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
+ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/p-cancelable": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+ "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz",
+ "integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-cache-control": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
+ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.12",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
+ "license": "MIT"
+ },
+ "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/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/podcast-index-api": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/podcast-index-api/-/podcast-index-api-1.1.10.tgz",
+ "integrity": "sha512-xOK8yN7Nn31IUGdA3wIzuDDGJCAIBOqv+steapUDTps4QNs5o8QOT8K0udXJKEpJr39TKy04zsaJuEmU6ofYJA==",
+ "license": "MIT",
+ "dependencies": {
+ "got": "^11.6.0"
+ }
+ },
+ "node_modules/prisma": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
+ "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
+ "devOptional": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@prisma/engines": "5.22.0"
+ },
+ "bin": {
+ "prisma": "build/index.js"
+ },
+ "engines": {
+ "node": ">=16.13"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ }
+ },
+ "node_modules/progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pump": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
+ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/quick-lru": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+ "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/random-bytes": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
+ "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/redis": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
+ "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "./packages/*"
+ ],
+ "dependencies": {
+ "@redis/bloom": "1.2.0",
+ "@redis/client": "1.6.1",
+ "@redis/graph": "1.1.1",
+ "@redis/json": "1.0.7",
+ "@redis/search": "1.2.0",
+ "@redis/time-series": "1.1.0"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-info": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redis-info/-/redis-info-3.1.0.tgz",
+ "integrity": "sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.11"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
+ "node_modules/resolve-alpn": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+ "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
+ "license": "MIT"
+ },
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
+ "node_modules/responselike": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
+ "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
+ "license": "MIT",
+ "dependencies": {
+ "lowercase-keys": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "deprecated": "Rimraf versions prior to v4 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "glob": "^7.1.3"
+ },
+ "bin": {
+ "rimraf": "bin.js"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/router/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/router/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/router/node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/rss-parser": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz",
+ "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^2.0.3",
+ "xml2js": "^0.5.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/sax": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
+ "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/semver": {
+ "version": "7.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
+ "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "license": "ISC"
+ },
+ "node_modules/slsk-client": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/slsk-client/-/slsk-client-1.1.0.tgz",
+ "integrity": "sha512-gnDt4FCyiweLeJRCPy+ANKIrn4ey2E+cE4uj0mqjQHlPjXtBIOglFXoDSNDxnACZFub+E0zXrYxCFyB8vabu2A==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^3.2.6"
+ }
+ },
+ "node_modules/slsk-client/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/slsk-client/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/speakeasy": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
+ "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
+ "license": "MIT",
+ "dependencies": {
+ "base32.js": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strtok3": {
+ "version": "10.3.4",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
+ "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/swagger-jsdoc": {
+ "version": "6.2.8",
+ "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz",
+ "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==",
+ "license": "MIT",
+ "dependencies": {
+ "commander": "6.2.0",
+ "doctrine": "3.0.0",
+ "glob": "7.1.6",
+ "lodash.mergewith": "^4.6.2",
+ "swagger-parser": "^10.0.3",
+ "yaml": "2.0.0-1"
+ },
+ "bin": {
+ "swagger-jsdoc": "bin/swagger-jsdoc.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/swagger-jsdoc/node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/swagger-jsdoc/node_modules/glob": {
+ "version": "7.1.6",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+ "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/swagger-jsdoc/node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/swagger-parser": {
+ "version": "10.0.3",
+ "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz",
+ "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/swagger-parser": "10.0.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/swagger-ui-dist": {
+ "version": "5.31.0",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz",
+ "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@scarf/scarf": "=1.4.0"
+ }
+ },
+ "node_modules/swagger-ui-express": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
+ "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
+ "license": "MIT",
+ "dependencies": {
+ "swagger-ui-dist": ">=5.0.0"
+ },
+ "engines": {
+ "node": ">= v0.10.32"
+ },
+ "peerDependencies": {
+ "express": ">=4.0.0 || >=5.0.0-beta"
+ }
+ },
+ "node_modules/tar": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
+ "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
+ "license": "ISC",
+ "dependencies": {
+ "chownr": "^2.0.0",
+ "fs-minipass": "^2.0.0",
+ "minipass": "^5.0.0",
+ "minizlib": "^2.1.1",
+ "mkdirp": "^1.0.3",
+ "yallist": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/token-types": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz",
+ "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.1.0",
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/token-types/node_modules/@borewit/text-codec": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz",
+ "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/tsx": {
+ "version": "4.21.0",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.27.0",
+ "get-tsconfig": "^4.7.5"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typedarray": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
+ "license": "MIT"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uid-safe": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
+ "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==",
+ "license": "MIT",
+ "dependencies": {
+ "random-bytes": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/uint8array-extras": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+ "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/validator": {
+ "version": "13.15.26",
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz",
+ "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "which": "bin/which"
+ }
+ },
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
+ "node_modules/wide-align": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
+ "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^1.0.2 || 2 || 3 || 4"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xml2js": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+ "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "2.0.0-1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
+ "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/z-schema": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
+ "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash.get": "^4.4.2",
+ "lodash.isequal": "^4.5.0",
+ "validator": "^13.7.0"
+ },
+ "bin": {
+ "z-schema": "bin/z-schema"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "commander": "^9.4.1"
+ }
+ },
+ "node_modules/z-schema/node_modules/commander": {
+ "version": "9.5.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
+ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": "^12.20.0 || >=14"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..fa97158
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "lidify-backend",
+ "version": "1.2.0",
+ "description": "Lidify backend API server",
+ "license": "GPL-3.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Chevron7Locked/lidify.git"
+ },
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc",
+ "start": "node dist/index.js",
+ "db:migrate": "prisma migrate deploy",
+ "db:studio": "prisma studio",
+ "seed:user": "tsx seeds/createUser.ts",
+ "test:smoke": "tsx scripts/smoke.ts",
+ "sync": "tsx src/workers/sync.ts"
+ },
+ "dependencies": {
+ "@bull-board/api": "^6.14.2",
+ "@bull-board/express": "^6.14.2",
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
+ "@prisma/client": "^5.22.0",
+ "@types/bull": "^3.15.9",
+ "@types/fluent-ffmpeg": "^2.1.28",
+ "@types/node-cron": "^3.0.11",
+ "@types/qrcode": "^1.5.6",
+ "@types/speakeasy": "^2.0.10",
+ "@types/swagger-jsdoc": "^6.0.4",
+ "@types/swagger-ui-express": "^4.1.8",
+ "axios": "^1.6.2",
+ "bcrypt": "^5.1.1",
+ "bull": "^4.16.5",
+ "connect-redis": "^7.1.0",
+ "cors": "^2.8.5",
+ "date-fns": "^4.1.0",
+ "dotenv": "^16.3.1",
+ "express": "^4.18.2",
+ "express-rate-limit": "^8.2.1",
+ "express-session": "^1.17.3",
+ "ffmpeg-static": "^5.2.0",
+ "fluent-ffmpeg": "^2.1.3",
+ "fuzzball": "^2.2.3",
+ "helmet": "^7.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "music-metadata": "^11.10.0",
+ "node-cron": "^4.2.1",
+ "p-queue": "^9.0.0",
+ "podcast-index-api": "^1.1.10",
+ "qrcode": "^1.5.4",
+ "redis": "^4.6.10",
+ "rss-parser": "^3.13.0",
+ "sharp": "^0.34.5",
+ "slsk-client": "^1.1.0",
+ "speakeasy": "^2.0.0",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@types/bcrypt": "^5.0.2",
+ "@types/express": "^4.17.21",
+ "@types/express-session": "^1.17.10",
+ "@types/jsonwebtoken": "^9.0.10",
+ "@types/node": "^20.10.4",
+ "prisma": "^5.22.0",
+ "tsx": "^4.7.0",
+ "typescript": "^5.3.3"
+ }
+}
diff --git a/backend/prisma/migrations/20251130000000_init/migration.sql b/backend/prisma/migrations/20251130000000_init/migration.sql
new file mode 100644
index 0000000..27f848c
--- /dev/null
+++ b/backend/prisma/migrations/20251130000000_init/migration.sql
@@ -0,0 +1,1128 @@
+-- CreateEnum
+CREATE TYPE "DiscoverStatus" AS ENUM ('ACTIVE', 'LIKED', 'MOVED', 'DELETED');
+
+-- CreateEnum
+CREATE TYPE "ListenSource" AS ENUM ('LIBRARY', 'DISCOVERY', 'DISCOVERY_KEPT');
+
+-- CreateEnum
+CREATE TYPE "AlbumLocation" AS ENUM ('LIBRARY', 'DISCOVER');
+
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL,
+ "username" TEXT NOT NULL,
+ "passwordHash" TEXT NOT NULL,
+ "role" TEXT NOT NULL DEFAULT 'user',
+ "onboardingComplete" BOOLEAN NOT NULL DEFAULT false,
+ "enrichmentSettings" JSONB,
+ "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
+ "twoFactorSecret" TEXT,
+ "twoFactorRecoveryCodes" TEXT,
+ "moodMixParams" JSONB,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UserSettings" (
+ "userId" TEXT NOT NULL,
+ "playbackQuality" TEXT NOT NULL DEFAULT 'original',
+ "wifiOnly" BOOLEAN NOT NULL DEFAULT false,
+ "offlineEnabled" BOOLEAN NOT NULL DEFAULT false,
+ "maxCacheSizeMb" INTEGER NOT NULL DEFAULT 10240,
+
+ CONSTRAINT "UserSettings_pkey" PRIMARY KEY ("userId")
+);
+
+-- CreateTable
+CREATE TABLE "PlaybackState" (
+ "userId" TEXT NOT NULL,
+ "playbackType" TEXT NOT NULL,
+ "trackId" TEXT,
+ "audiobookId" TEXT,
+ "podcastId" TEXT,
+ "queue" JSONB,
+ "currentIndex" INTEGER NOT NULL DEFAULT 0,
+ "isShuffle" BOOLEAN NOT NULL DEFAULT false,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PlaybackState_pkey" PRIMARY KEY ("userId")
+);
+
+-- CreateTable
+CREATE TABLE "SystemSettings" (
+ "id" TEXT NOT NULL DEFAULT 'default',
+ "lidarrEnabled" BOOLEAN NOT NULL DEFAULT true,
+ "lidarrUrl" TEXT DEFAULT 'http://localhost:8686',
+ "lidarrApiKey" TEXT,
+ "openaiEnabled" BOOLEAN NOT NULL DEFAULT false,
+ "openaiApiKey" TEXT,
+ "openaiModel" TEXT DEFAULT 'gpt-4',
+ "openaiBaseUrl" TEXT,
+ "fanartEnabled" BOOLEAN NOT NULL DEFAULT false,
+ "fanartApiKey" TEXT,
+ "audiobookshelfEnabled" BOOLEAN NOT NULL DEFAULT false,
+ "audiobookshelfUrl" TEXT DEFAULT 'http://localhost:13378',
+ "audiobookshelfApiKey" TEXT,
+ "soulseekUsername" TEXT,
+ "soulseekPassword" TEXT,
+ "spotifyClientId" TEXT,
+ "spotifyClientSecret" TEXT,
+ "musicPath" TEXT DEFAULT '/music',
+ "downloadPath" TEXT DEFAULT '/downloads',
+ "autoSync" BOOLEAN NOT NULL DEFAULT true,
+ "autoEnrichMetadata" BOOLEAN NOT NULL DEFAULT true,
+ "maxConcurrentDownloads" INTEGER NOT NULL DEFAULT 3,
+ "downloadRetryAttempts" INTEGER NOT NULL DEFAULT 3,
+ "transcodeCacheMaxGb" INTEGER NOT NULL DEFAULT 10,
+ "downloadSource" TEXT NOT NULL DEFAULT 'soulseek',
+ "soulseekFallback" TEXT NOT NULL DEFAULT 'none',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "SystemSettings_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Artist" (
+ "id" TEXT NOT NULL,
+ "mbid" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "normalizedName" TEXT NOT NULL DEFAULT '',
+ "summary" TEXT,
+ "heroUrl" TEXT,
+ "genres" JSONB,
+ "lastSynced" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastEnriched" TIMESTAMP(3),
+ "enrichmentStatus" TEXT NOT NULL DEFAULT 'pending',
+ "searchVector" tsvector,
+
+ CONSTRAINT "Artist_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Album" (
+ "id" TEXT NOT NULL,
+ "rgMbid" TEXT NOT NULL,
+ "artistId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "year" INTEGER,
+ "coverUrl" TEXT,
+ "primaryType" TEXT NOT NULL,
+ "label" TEXT,
+ "genres" JSONB,
+ "lastSynced" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "location" "AlbumLocation" NOT NULL DEFAULT 'LIBRARY',
+ "searchVector" tsvector,
+
+ CONSTRAINT "Album_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Track" (
+ "id" TEXT NOT NULL,
+ "albumId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "trackNo" INTEGER NOT NULL,
+ "duration" INTEGER NOT NULL,
+ "mime" TEXT,
+ "searchVector" tsvector,
+ "filePath" TEXT NOT NULL,
+ "fileModified" TIMESTAMP(3) NOT NULL,
+ "fileSize" INTEGER NOT NULL,
+ "bpm" DOUBLE PRECISION,
+ "beatsCount" INTEGER,
+ "key" TEXT,
+ "keyScale" TEXT,
+ "keyStrength" DOUBLE PRECISION,
+ "energy" DOUBLE PRECISION,
+ "loudness" DOUBLE PRECISION,
+ "dynamicRange" DOUBLE PRECISION,
+ "danceability" DOUBLE PRECISION,
+ "valence" DOUBLE PRECISION,
+ "arousal" DOUBLE PRECISION,
+ "instrumentalness" DOUBLE PRECISION,
+ "acousticness" DOUBLE PRECISION,
+ "speechiness" DOUBLE PRECISION,
+ "moodHappy" DOUBLE PRECISION,
+ "moodSad" DOUBLE PRECISION,
+ "moodRelaxed" DOUBLE PRECISION,
+ "moodAggressive" DOUBLE PRECISION,
+ "moodParty" DOUBLE PRECISION,
+ "moodAcoustic" DOUBLE PRECISION,
+ "moodElectronic" DOUBLE PRECISION,
+ "danceabilityMl" DOUBLE PRECISION,
+ "moodTags" TEXT[],
+ "essentiaGenres" TEXT[],
+ "lastfmTags" TEXT[],
+ "analysisStatus" TEXT NOT NULL DEFAULT 'pending',
+ "analysisVersion" TEXT,
+ "analysisMode" TEXT,
+ "analyzedAt" TIMESTAMP(3),
+ "analysisError" TEXT,
+ "analysisRetryCount" INTEGER NOT NULL DEFAULT 0,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Track_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "TranscodedFile" (
+ "id" TEXT NOT NULL,
+ "trackId" TEXT NOT NULL,
+ "quality" TEXT NOT NULL,
+ "cachePath" TEXT NOT NULL,
+ "cacheSize" INTEGER NOT NULL,
+ "sourceModified" TIMESTAMP(3) NOT NULL,
+ "lastAccessed" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "TranscodedFile_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Play" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "trackId" TEXT NOT NULL,
+ "playedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "source" "ListenSource" NOT NULL DEFAULT 'LIBRARY',
+
+ CONSTRAINT "Play_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Playlist" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "mixId" TEXT,
+ "name" TEXT NOT NULL,
+ "isPublic" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "spotifyPlaylistId" TEXT,
+ "spotifyPlaylistUrl" TEXT,
+
+ CONSTRAINT "Playlist_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "HiddenPlaylist" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "playlistId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "HiddenPlaylist_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PlaylistItem" (
+ "id" TEXT NOT NULL,
+ "playlistId" TEXT NOT NULL,
+ "trackId" TEXT NOT NULL,
+ "sort" INTEGER NOT NULL,
+
+ CONSTRAINT "PlaylistItem_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PlaylistPendingTrack" (
+ "id" TEXT NOT NULL,
+ "playlistId" TEXT NOT NULL,
+ "spotifyArtist" TEXT NOT NULL,
+ "spotifyTitle" TEXT NOT NULL,
+ "spotifyAlbum" TEXT NOT NULL,
+ "albumMbid" TEXT,
+ "artistMbid" TEXT,
+ "deezerPreviewUrl" TEXT,
+ "sort" INTEGER NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PlaylistPendingTrack_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "SpotifyImportJob" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "spotifyPlaylistId" TEXT NOT NULL,
+ "playlistName" TEXT NOT NULL,
+ "status" TEXT NOT NULL,
+ "progress" INTEGER NOT NULL DEFAULT 0,
+ "albumsTotal" INTEGER NOT NULL,
+ "albumsCompleted" INTEGER NOT NULL DEFAULT 0,
+ "tracksTotal" INTEGER NOT NULL,
+ "tracksMatched" INTEGER NOT NULL DEFAULT 0,
+ "tracksDownloadable" INTEGER NOT NULL DEFAULT 0,
+ "createdPlaylistId" TEXT,
+ "error" TEXT,
+ "pendingTracks" JSONB NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "SpotifyImportJob_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Genre" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+
+ CONSTRAINT "Genre_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "TrackGenre" (
+ "trackId" TEXT NOT NULL,
+ "genreId" TEXT NOT NULL,
+
+ CONSTRAINT "TrackGenre_pkey" PRIMARY KEY ("trackId","genreId")
+);
+
+-- CreateTable
+CREATE TABLE "SimilarArtist" (
+ "fromArtistId" TEXT NOT NULL,
+ "toArtistId" TEXT NOT NULL,
+ "weight" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
+
+ CONSTRAINT "SimilarArtist_pkey" PRIMARY KEY ("fromArtistId","toArtistId")
+);
+
+-- CreateTable
+CREATE TABLE "OwnedAlbum" (
+ "artistId" TEXT NOT NULL,
+ "rgMbid" TEXT NOT NULL,
+ "source" TEXT NOT NULL,
+
+ CONSTRAINT "OwnedAlbum_pkey" PRIMARY KEY ("artistId","rgMbid")
+);
+
+-- CreateTable
+CREATE TABLE "DownloadJob" (
+ "id" TEXT NOT NULL,
+ "correlationId" TEXT,
+ "userId" TEXT NOT NULL,
+ "subject" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "targetMbid" TEXT NOT NULL,
+ "status" TEXT NOT NULL,
+ "error" TEXT,
+ "lidarrRef" TEXT,
+ "lidarrAlbumId" INTEGER,
+ "metadata" JSONB,
+ "attempts" INTEGER NOT NULL DEFAULT 0,
+ "startedAt" TIMESTAMP(3),
+ "completedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "discoveryBatchId" TEXT,
+ "triedReleases" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "releaseIndex" INTEGER NOT NULL DEFAULT 0,
+ "artistMbid" TEXT,
+ "cleared" BOOLEAN NOT NULL DEFAULT false,
+
+ CONSTRAINT "DownloadJob_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ListeningState" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "kind" TEXT NOT NULL,
+ "entityId" TEXT NOT NULL,
+ "trackId" TEXT,
+ "positionMs" INTEGER NOT NULL DEFAULT 0,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ListeningState_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DiscoveryAlbum" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "rgMbid" TEXT NOT NULL,
+ "artistName" TEXT NOT NULL,
+ "artistMbid" TEXT,
+ "albumTitle" TEXT NOT NULL,
+ "lidarrAlbumId" INTEGER,
+ "downloadedAt" TIMESTAMP(3),
+ "folderPath" TEXT NOT NULL DEFAULT '',
+ "weekStartDate" TIMESTAMP(3) NOT NULL,
+ "weekEndDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "status" "DiscoverStatus" NOT NULL DEFAULT 'ACTIVE',
+ "likedAt" TIMESTAMP(3),
+ "similarity" DOUBLE PRECISION,
+ "tier" TEXT,
+
+ CONSTRAINT "DiscoveryAlbum_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DiscoveryTrack" (
+ "id" TEXT NOT NULL,
+ "discoveryAlbumId" TEXT NOT NULL,
+ "trackId" TEXT,
+ "fileName" TEXT NOT NULL,
+ "filePath" TEXT NOT NULL,
+ "inPlaylistCount" INTEGER NOT NULL DEFAULT 0,
+ "userKept" BOOLEAN NOT NULL DEFAULT false,
+ "lastPlayedAt" TIMESTAMP(3),
+
+ CONSTRAINT "DiscoveryTrack_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "LikedTrack" (
+ "userId" TEXT NOT NULL,
+ "trackId" TEXT NOT NULL,
+ "likedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "LikedTrack_pkey" PRIMARY KEY ("userId","trackId")
+);
+
+-- CreateTable
+CREATE TABLE "DislikedEntity" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "entityType" TEXT NOT NULL,
+ "entityId" TEXT NOT NULL,
+ "dislikedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DislikedEntity_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "CachedTrack" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "trackId" TEXT NOT NULL,
+ "localPath" TEXT NOT NULL,
+ "quality" TEXT NOT NULL,
+ "fileSizeMb" DOUBLE PRECISION NOT NULL,
+ "cachedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastAccessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CachedTrack_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AudiobookProgress" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "audiobookshelfId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "author" TEXT,
+ "coverUrl" TEXT,
+ "currentTime" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "duration" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "isFinished" BOOLEAN NOT NULL DEFAULT false,
+ "lastPlayedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AudiobookProgress_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Audiobook" (
+ "id" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "author" TEXT,
+ "narrator" TEXT,
+ "description" TEXT,
+ "publishedYear" INTEGER,
+ "publisher" TEXT,
+ "series" TEXT,
+ "seriesSequence" TEXT,
+ "duration" DOUBLE PRECISION,
+ "numTracks" INTEGER,
+ "numChapters" INTEGER,
+ "size" BIGINT,
+ "isbn" TEXT,
+ "asin" TEXT,
+ "language" TEXT,
+ "genres" TEXT[],
+ "tags" TEXT[],
+ "localCoverPath" TEXT,
+ "coverUrl" TEXT,
+ "audioUrl" TEXT NOT NULL,
+ "libraryId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Audiobook_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "podcast_recommendations" (
+ "id" TEXT NOT NULL,
+ "podcastId" TEXT NOT NULL,
+ "recommendedId" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "author" TEXT,
+ "description" TEXT,
+ "coverUrl" TEXT,
+ "episodeCount" INTEGER NOT NULL DEFAULT 0,
+ "feedUrl" TEXT,
+ "itunesId" TEXT,
+ "score" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "cachedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "podcast_recommendations_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Podcast" (
+ "id" TEXT NOT NULL,
+ "feedUrl" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "author" TEXT,
+ "description" TEXT,
+ "imageUrl" TEXT,
+ "localCoverPath" TEXT,
+ "itunesId" TEXT,
+ "language" TEXT,
+ "explicit" BOOLEAN NOT NULL DEFAULT false,
+ "episodeCount" INTEGER NOT NULL DEFAULT 0,
+ "lastRefreshed" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "refreshInterval" INTEGER NOT NULL DEFAULT 3600,
+ "autoRefresh" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Podcast_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PodcastEpisode" (
+ "id" TEXT NOT NULL,
+ "podcastId" TEXT NOT NULL,
+ "guid" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "description" TEXT,
+ "audioUrl" TEXT NOT NULL,
+ "duration" INTEGER NOT NULL DEFAULT 0,
+ "publishedAt" TIMESTAMP(3) NOT NULL,
+ "episodeNumber" INTEGER,
+ "season" INTEGER,
+ "imageUrl" TEXT,
+ "localCoverPath" TEXT,
+ "fileSize" INTEGER,
+ "mimeType" TEXT DEFAULT 'audio/mpeg',
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PodcastEpisode_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PodcastSubscription" (
+ "userId" TEXT NOT NULL,
+ "podcastId" TEXT NOT NULL,
+ "subscribedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PodcastSubscription_pkey" PRIMARY KEY ("userId","podcastId")
+);
+
+-- CreateTable
+CREATE TABLE "PodcastProgress" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "episodeId" TEXT NOT NULL,
+ "currentTime" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "duration" DOUBLE PRECISION NOT NULL DEFAULT 0,
+ "isFinished" BOOLEAN NOT NULL DEFAULT false,
+ "lastPlayedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PodcastProgress_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "PodcastDownload" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "episodeId" TEXT NOT NULL,
+ "localPath" TEXT NOT NULL,
+ "fileSizeMb" DOUBLE PRECISION NOT NULL,
+ "downloadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastAccessedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "PodcastDownload_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DiscoverExclusion" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "albumMbid" TEXT NOT NULL,
+ "artistName" TEXT,
+ "albumTitle" TEXT,
+ "lastSuggestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "DiscoverExclusion_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UserDiscoverConfig" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "playlistSize" INTEGER NOT NULL DEFAULT 40,
+ "maxRetryAttempts" INTEGER NOT NULL DEFAULT 3,
+ "exclusionMonths" INTEGER NOT NULL DEFAULT 6,
+ "downloadRatio" DOUBLE PRECISION NOT NULL DEFAULT 1.3,
+ "enabled" BOOLEAN NOT NULL DEFAULT true,
+ "lastGeneratedAt" TIMESTAMP(3),
+
+ CONSTRAINT "UserDiscoverConfig_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "UnavailableAlbum" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "artistName" TEXT NOT NULL,
+ "albumTitle" TEXT NOT NULL,
+ "albumMbid" TEXT NOT NULL,
+ "artistMbid" TEXT,
+ "similarity" DOUBLE PRECISION NOT NULL,
+ "tier" TEXT NOT NULL,
+ "weekStartDate" TIMESTAMP(3) NOT NULL,
+ "previewUrl" TEXT,
+ "deezerTrackId" TEXT,
+ "deezerAlbumId" TEXT,
+ "attemptNumber" INTEGER NOT NULL DEFAULT 0,
+ "originalAlbumId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "UnavailableAlbum_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DiscoveryBatch" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "weekStart" TIMESTAMP(3) NOT NULL,
+ "targetSongCount" INTEGER NOT NULL,
+ "status" TEXT NOT NULL DEFAULT 'downloading',
+ "totalAlbums" INTEGER NOT NULL DEFAULT 0,
+ "completedAlbums" INTEGER NOT NULL DEFAULT 0,
+ "failedAlbums" INTEGER NOT NULL DEFAULT 0,
+ "finalSongCount" INTEGER NOT NULL DEFAULT 0,
+ "logs" JSONB,
+ "errorMessage" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "completedAt" TIMESTAMP(3),
+
+ CONSTRAINT "DiscoveryBatch_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ApiKey" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "key" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "lastUsed" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "DeviceLinkCode" (
+ "id" TEXT NOT NULL,
+ "code" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "usedAt" TIMESTAMP(3),
+ "deviceName" TEXT,
+ "apiKeyId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "DeviceLinkCode_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Notification" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "message" TEXT,
+ "metadata" JSONB,
+ "read" BOOLEAN NOT NULL DEFAULT false,
+ "cleared" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
+
+-- CreateIndex
+CREATE INDEX "PlaybackState_userId_idx" ON "PlaybackState"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Artist_mbid_key" ON "Artist"("mbid");
+
+-- CreateIndex
+CREATE INDEX "Artist_name_idx" ON "Artist"("name");
+
+-- CreateIndex
+CREATE INDEX "Artist_normalizedName_idx" ON "Artist"("normalizedName");
+
+-- CreateIndex
+CREATE INDEX "Artist_searchVector_idx" ON "Artist" USING GIN ("searchVector");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Album_rgMbid_key" ON "Album"("rgMbid");
+
+-- CreateIndex
+CREATE INDEX "Album_artistId_idx" ON "Album"("artistId");
+
+-- CreateIndex
+CREATE INDEX "Album_location_idx" ON "Album"("location");
+
+-- CreateIndex
+CREATE INDEX "Album_title_idx" ON "Album"("title");
+
+-- CreateIndex
+CREATE INDEX "Album_searchVector_idx" ON "Album" USING GIN ("searchVector");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Track_filePath_key" ON "Track"("filePath");
+
+-- CreateIndex
+CREATE INDEX "Track_albumId_idx" ON "Track"("albumId");
+
+-- CreateIndex
+CREATE INDEX "Track_fileModified_idx" ON "Track"("fileModified");
+
+-- CreateIndex
+CREATE INDEX "Track_title_idx" ON "Track"("title");
+
+-- CreateIndex
+CREATE INDEX "Track_searchVector_idx" ON "Track" USING GIN ("searchVector");
+
+-- CreateIndex
+CREATE INDEX "Track_analysisStatus_idx" ON "Track"("analysisStatus");
+
+-- CreateIndex
+CREATE INDEX "Track_bpm_idx" ON "Track"("bpm");
+
+-- CreateIndex
+CREATE INDEX "Track_energy_idx" ON "Track"("energy");
+
+-- CreateIndex
+CREATE INDEX "Track_valence_idx" ON "Track"("valence");
+
+-- CreateIndex
+CREATE INDEX "Track_danceability_idx" ON "Track"("danceability");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "TranscodedFile_cachePath_key" ON "TranscodedFile"("cachePath");
+
+-- CreateIndex
+CREATE INDEX "TranscodedFile_trackId_quality_idx" ON "TranscodedFile"("trackId", "quality");
+
+-- CreateIndex
+CREATE INDEX "TranscodedFile_lastAccessed_idx" ON "TranscodedFile"("lastAccessed");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "TranscodedFile_trackId_quality_key" ON "TranscodedFile"("trackId", "quality");
+
+-- CreateIndex
+CREATE INDEX "Play_userId_playedAt_idx" ON "Play"("userId", "playedAt");
+
+-- CreateIndex
+CREATE INDEX "Play_trackId_idx" ON "Play"("trackId");
+
+-- CreateIndex
+CREATE INDEX "Play_source_idx" ON "Play"("source");
+
+-- CreateIndex
+CREATE INDEX "Playlist_userId_idx" ON "Playlist"("userId");
+
+-- CreateIndex
+CREATE INDEX "Playlist_spotifyPlaylistId_idx" ON "Playlist"("spotifyPlaylistId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Playlist_userId_mixId_key" ON "Playlist"("userId", "mixId");
+
+-- CreateIndex
+CREATE INDEX "HiddenPlaylist_userId_idx" ON "HiddenPlaylist"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "HiddenPlaylist_userId_playlistId_key" ON "HiddenPlaylist"("userId", "playlistId");
+
+-- CreateIndex
+CREATE INDEX "PlaylistItem_playlistId_sort_idx" ON "PlaylistItem"("playlistId", "sort");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlaylistItem_playlistId_trackId_key" ON "PlaylistItem"("playlistId", "trackId");
+
+-- CreateIndex
+CREATE INDEX "PlaylistPendingTrack_playlistId_idx" ON "PlaylistPendingTrack"("playlistId");
+
+-- CreateIndex
+CREATE INDEX "PlaylistPendingTrack_albumMbid_idx" ON "PlaylistPendingTrack"("albumMbid");
+
+-- CreateIndex
+CREATE INDEX "PlaylistPendingTrack_artistMbid_idx" ON "PlaylistPendingTrack"("artistMbid");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PlaylistPendingTrack_playlistId_spotifyArtist_spotifyTitle_key" ON "PlaylistPendingTrack"("playlistId", "spotifyArtist", "spotifyTitle");
+
+-- CreateIndex
+CREATE INDEX "SpotifyImportJob_userId_idx" ON "SpotifyImportJob"("userId");
+
+-- CreateIndex
+CREATE INDEX "SpotifyImportJob_status_idx" ON "SpotifyImportJob"("status");
+
+-- CreateIndex
+CREATE INDEX "SpotifyImportJob_createdAt_idx" ON "SpotifyImportJob"("createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Genre_name_key" ON "Genre"("name");
+
+-- CreateIndex
+CREATE INDEX "TrackGenre_genreId_idx" ON "TrackGenre"("genreId");
+
+-- CreateIndex
+CREATE INDEX "SimilarArtist_fromArtistId_idx" ON "SimilarArtist"("fromArtistId");
+
+-- CreateIndex
+CREATE INDEX "OwnedAlbum_artistId_idx" ON "OwnedAlbum"("artistId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DownloadJob_correlationId_key" ON "DownloadJob"("correlationId");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_userId_status_idx" ON "DownloadJob"("userId", "status");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_status_idx" ON "DownloadJob"("status");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_discoveryBatchId_idx" ON "DownloadJob"("discoveryBatchId");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_correlationId_idx" ON "DownloadJob"("correlationId");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_startedAt_idx" ON "DownloadJob"("startedAt");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_lidarrRef_idx" ON "DownloadJob"("lidarrRef");
+
+-- CreateIndex
+CREATE INDEX "DownloadJob_artistMbid_idx" ON "DownloadJob"("artistMbid");
+
+-- CreateIndex
+CREATE INDEX "ListeningState_userId_idx" ON "ListeningState"("userId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ListeningState_userId_kind_entityId_key" ON "ListeningState"("userId", "kind", "entityId");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryAlbum_userId_weekStartDate_idx" ON "DiscoveryAlbum"("userId", "weekStartDate");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryAlbum_downloadedAt_idx" ON "DiscoveryAlbum"("downloadedAt");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryAlbum_status_idx" ON "DiscoveryAlbum"("status");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DiscoveryAlbum_userId_weekStartDate_rgMbid_key" ON "DiscoveryAlbum"("userId", "weekStartDate", "rgMbid");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryTrack_discoveryAlbumId_idx" ON "DiscoveryTrack"("discoveryAlbumId");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryTrack_userKept_idx" ON "DiscoveryTrack"("userKept");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryTrack_lastPlayedAt_idx" ON "DiscoveryTrack"("lastPlayedAt");
+
+-- CreateIndex
+CREATE INDEX "LikedTrack_userId_idx" ON "LikedTrack"("userId");
+
+-- CreateIndex
+CREATE INDEX "LikedTrack_likedAt_idx" ON "LikedTrack"("likedAt");
+
+-- CreateIndex
+CREATE INDEX "DislikedEntity_userId_entityType_idx" ON "DislikedEntity"("userId", "entityType");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DislikedEntity_userId_entityType_entityId_key" ON "DislikedEntity"("userId", "entityType", "entityId");
+
+-- CreateIndex
+CREATE INDEX "CachedTrack_userId_idx" ON "CachedTrack"("userId");
+
+-- CreateIndex
+CREATE INDEX "CachedTrack_lastAccessedAt_idx" ON "CachedTrack"("lastAccessedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "CachedTrack_userId_trackId_quality_key" ON "CachedTrack"("userId", "trackId", "quality");
+
+-- CreateIndex
+CREATE INDEX "AudiobookProgress_userId_lastPlayedAt_idx" ON "AudiobookProgress"("userId", "lastPlayedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "AudiobookProgress_userId_audiobookshelfId_key" ON "AudiobookProgress"("userId", "audiobookshelfId");
+
+-- CreateIndex
+CREATE INDEX "Audiobook_title_idx" ON "Audiobook"("title");
+
+-- CreateIndex
+CREATE INDEX "Audiobook_author_idx" ON "Audiobook"("author");
+
+-- CreateIndex
+CREATE INDEX "Audiobook_series_idx" ON "Audiobook"("series");
+
+-- CreateIndex
+CREATE INDEX "Audiobook_lastSyncedAt_idx" ON "Audiobook"("lastSyncedAt");
+
+-- CreateIndex
+CREATE INDEX "podcast_recommendations_podcastId_expiresAt_idx" ON "podcast_recommendations"("podcastId", "expiresAt");
+
+-- CreateIndex
+CREATE INDEX "podcast_recommendations_expiresAt_idx" ON "podcast_recommendations"("expiresAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Podcast_feedUrl_key" ON "Podcast"("feedUrl");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Podcast_itunesId_key" ON "Podcast"("itunesId");
+
+-- CreateIndex
+CREATE INDEX "Podcast_itunesId_idx" ON "Podcast"("itunesId");
+
+-- CreateIndex
+CREATE INDEX "Podcast_lastRefreshed_idx" ON "Podcast"("lastRefreshed");
+
+-- CreateIndex
+CREATE INDEX "PodcastEpisode_podcastId_publishedAt_idx" ON "PodcastEpisode"("podcastId", "publishedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PodcastEpisode_podcastId_guid_key" ON "PodcastEpisode"("podcastId", "guid");
+
+-- CreateIndex
+CREATE INDEX "PodcastSubscription_userId_idx" ON "PodcastSubscription"("userId");
+
+-- CreateIndex
+CREATE INDEX "PodcastSubscription_podcastId_idx" ON "PodcastSubscription"("podcastId");
+
+-- CreateIndex
+CREATE INDEX "PodcastProgress_userId_lastPlayedAt_idx" ON "PodcastProgress"("userId", "lastPlayedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PodcastProgress_userId_episodeId_key" ON "PodcastProgress"("userId", "episodeId");
+
+-- CreateIndex
+CREATE INDEX "PodcastDownload_userId_idx" ON "PodcastDownload"("userId");
+
+-- CreateIndex
+CREATE INDEX "PodcastDownload_lastAccessedAt_idx" ON "PodcastDownload"("lastAccessedAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "PodcastDownload_userId_episodeId_key" ON "PodcastDownload"("userId", "episodeId");
+
+-- CreateIndex
+CREATE INDEX "DiscoverExclusion_userId_expiresAt_idx" ON "DiscoverExclusion"("userId", "expiresAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DiscoverExclusion_userId_albumMbid_key" ON "DiscoverExclusion"("userId", "albumMbid");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "UserDiscoverConfig_userId_key" ON "UserDiscoverConfig"("userId");
+
+-- CreateIndex
+CREATE INDEX "UnavailableAlbum_userId_weekStartDate_idx" ON "UnavailableAlbum"("userId", "weekStartDate");
+
+-- CreateIndex
+CREATE INDEX "UnavailableAlbum_userId_weekStartDate_attemptNumber_idx" ON "UnavailableAlbum"("userId", "weekStartDate", "attemptNumber");
+
+-- CreateIndex
+CREATE INDEX "UnavailableAlbum_originalAlbumId_idx" ON "UnavailableAlbum"("originalAlbumId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "UnavailableAlbum_userId_weekStartDate_albumMbid_key" ON "UnavailableAlbum"("userId", "weekStartDate", "albumMbid");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryBatch_userId_weekStart_idx" ON "DiscoveryBatch"("userId", "weekStart");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryBatch_status_idx" ON "DiscoveryBatch"("status");
+
+-- CreateIndex
+CREATE INDEX "DiscoveryBatch_createdAt_idx" ON "DiscoveryBatch"("createdAt");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApiKey_key_key" ON "ApiKey"("key");
+
+-- CreateIndex
+CREATE INDEX "ApiKey_key_idx" ON "ApiKey"("key");
+
+-- CreateIndex
+CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId");
+
+-- CreateIndex
+CREATE INDEX "ApiKey_lastUsed_idx" ON "ApiKey"("lastUsed");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "DeviceLinkCode_code_key" ON "DeviceLinkCode"("code");
+
+-- CreateIndex
+CREATE INDEX "DeviceLinkCode_code_expiresAt_idx" ON "DeviceLinkCode"("code", "expiresAt");
+
+-- CreateIndex
+CREATE INDEX "DeviceLinkCode_userId_idx" ON "DeviceLinkCode"("userId");
+
+-- CreateIndex
+CREATE INDEX "Notification_userId_cleared_idx" ON "Notification"("userId", "cleared");
+
+-- CreateIndex
+CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read");
+
+-- CreateIndex
+CREATE INDEX "Notification_createdAt_idx" ON "Notification"("createdAt");
+
+-- AddForeignKey
+ALTER TABLE "UserSettings" ADD CONSTRAINT "UserSettings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PlaybackState" ADD CONSTRAINT "PlaybackState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Album" ADD CONSTRAINT "Album_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Track" ADD CONSTRAINT "Track_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "TranscodedFile" ADD CONSTRAINT "TranscodedFile_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Play" ADD CONSTRAINT "Play_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Play" ADD CONSTRAINT "Play_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Playlist" ADD CONSTRAINT "Playlist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "HiddenPlaylist" ADD CONSTRAINT "HiddenPlaylist_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "HiddenPlaylist" ADD CONSTRAINT "HiddenPlaylist_playlistId_fkey" FOREIGN KEY ("playlistId") REFERENCES "Playlist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PlaylistItem" ADD CONSTRAINT "PlaylistItem_playlistId_fkey" FOREIGN KEY ("playlistId") REFERENCES "Playlist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PlaylistItem" ADD CONSTRAINT "PlaylistItem_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PlaylistPendingTrack" ADD CONSTRAINT "PlaylistPendingTrack_playlistId_fkey" FOREIGN KEY ("playlistId") REFERENCES "Playlist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "SpotifyImportJob" ADD CONSTRAINT "SpotifyImportJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "TrackGenre" ADD CONSTRAINT "TrackGenre_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "TrackGenre" ADD CONSTRAINT "TrackGenre_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "SimilarArtist" ADD CONSTRAINT "SimilarArtist_fromArtistId_fkey" FOREIGN KEY ("fromArtistId") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "SimilarArtist" ADD CONSTRAINT "SimilarArtist_toArtistId_fkey" FOREIGN KEY ("toArtistId") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "OwnedAlbum" ADD CONSTRAINT "OwnedAlbum_artistId_fkey" FOREIGN KEY ("artistId") REFERENCES "Artist"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DownloadJob" ADD CONSTRAINT "DownloadJob_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DownloadJob" ADD CONSTRAINT "DownloadJob_discoveryBatchId_fkey" FOREIGN KEY ("discoveryBatchId") REFERENCES "DiscoveryBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ListeningState" ADD CONSTRAINT "ListeningState_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DiscoveryAlbum" ADD CONSTRAINT "DiscoveryAlbum_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DiscoveryTrack" ADD CONSTRAINT "DiscoveryTrack_discoveryAlbumId_fkey" FOREIGN KEY ("discoveryAlbumId") REFERENCES "DiscoveryAlbum"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "LikedTrack" ADD CONSTRAINT "LikedTrack_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "LikedTrack" ADD CONSTRAINT "LikedTrack_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DislikedEntity" ADD CONSTRAINT "DislikedEntity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CachedTrack" ADD CONSTRAINT "CachedTrack_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "CachedTrack" ADD CONSTRAINT "CachedTrack_trackId_fkey" FOREIGN KEY ("trackId") REFERENCES "Track"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AudiobookProgress" ADD CONSTRAINT "AudiobookProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastEpisode" ADD CONSTRAINT "PodcastEpisode_podcastId_fkey" FOREIGN KEY ("podcastId") REFERENCES "Podcast"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastSubscription" ADD CONSTRAINT "PodcastSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastSubscription" ADD CONSTRAINT "PodcastSubscription_podcastId_fkey" FOREIGN KEY ("podcastId") REFERENCES "Podcast"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastProgress" ADD CONSTRAINT "PodcastProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastProgress" ADD CONSTRAINT "PodcastProgress_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "PodcastEpisode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastDownload" ADD CONSTRAINT "PodcastDownload_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "PodcastDownload" ADD CONSTRAINT "PodcastDownload_episodeId_fkey" FOREIGN KEY ("episodeId") REFERENCES "PodcastEpisode"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DiscoverExclusion" ADD CONSTRAINT "DiscoverExclusion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UserDiscoverConfig" ADD CONSTRAINT "UserDiscoverConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "UnavailableAlbum" ADD CONSTRAINT "UnavailableAlbum_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "DeviceLinkCode" ADD CONSTRAINT "DeviceLinkCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
diff --git a/backend/prisma/migrations/20251225000000_add_missing_track_updated_at/migration.sql b/backend/prisma/migrations/20251225000000_add_missing_track_updated_at/migration.sql
new file mode 100644
index 0000000..b03e406
--- /dev/null
+++ b/backend/prisma/migrations/20251225000000_add_missing_track_updated_at/migration.sql
@@ -0,0 +1,12 @@
+-- Add updatedAt column to Track if it doesn't exist
+-- This handles databases that were created before this column was added to the schema
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'Track' AND column_name = 'updatedAt'
+ ) THEN
+ ALTER TABLE "Track" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
+ END IF;
+END $$;
diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..fbffa92
--- /dev/null
+++ b/backend/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "postgresql"
\ No newline at end of file
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
new file mode 100644
index 0000000..75cecd7
--- /dev/null
+++ b/backend/prisma/schema.prisma
@@ -0,0 +1,926 @@
+generator client {
+ provider = "prisma-client-js"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model User {
+ id String @id @default(cuid())
+ username String @unique
+ passwordHash String
+ role String @default("user") // user, admin
+ onboardingComplete Boolean @default(false) // Tracks if user completed setup
+ enrichmentSettings Json? // JSON settings for metadata enrichment
+ twoFactorEnabled Boolean @default(false) // 2FA enabled flag
+ twoFactorSecret String? // TOTP secret (encrypted)
+ twoFactorRecoveryCodes String? // Recovery codes (encrypted, comma-separated hashed codes)
+ moodMixParams Json? // Saved mood mix parameters for "Your Mood Mix"
+ createdAt DateTime @default(now())
+
+ plays Play[]
+ playlists Playlist[]
+ listeningState ListeningState[]
+ downloadJobs DownloadJob[]
+ spotifyImportJobs SpotifyImportJob[]
+ settings UserSettings?
+ playbackState PlaybackState?
+ likedTracks LikedTrack[]
+ dislikedEntities DislikedEntity[]
+ cachedTracks CachedTrack[]
+ audiobookProgress AudiobookProgress[]
+ podcastSubscriptions PodcastSubscription[]
+ podcastProgress PodcastProgress[]
+ podcastDownloads PodcastDownload[]
+ discoveryAlbums DiscoveryAlbum[]
+ discoverExclusions DiscoverExclusion[]
+ discoverConfig UserDiscoverConfig?
+ unavailableAlbums UnavailableAlbum[]
+ apiKeys ApiKey[]
+ deviceLinkCodes DeviceLinkCode[]
+ hiddenPlaylists HiddenPlaylist[]
+ notifications Notification[]
+}
+
+model UserSettings {
+ userId String @id
+ playbackQuality String @default("original") // original, high, medium, low
+ wifiOnly Boolean @default(false)
+ offlineEnabled Boolean @default(false)
+ maxCacheSizeMb Int @default(10240) // 10GB default
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+
+model PlaybackState {
+ userId String @id
+ playbackType String // track, audiobook, podcast
+ trackId String? // For music tracks
+ audiobookId String? // For audiobooks (audiobookshelfId)
+ podcastId String? // For podcasts (format: podcastId:episodeId)
+ queue Json? // JSON array of track IDs
+ currentIndex Int @default(0)
+ isShuffle Boolean @default(false)
+ updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+}
+
+model SystemSettings {
+ id String @id @default("default")
+
+ // === Download Services ===
+ // Lidarr
+ lidarrEnabled Boolean @default(true)
+ lidarrUrl String? @default("http://localhost:8686")
+ lidarrApiKey String? // Encrypted
+
+ // === AI Services ===
+ // OpenAI (for future AI features)
+ openaiEnabled Boolean @default(false)
+ openaiApiKey String? // Encrypted
+ openaiModel String? @default("gpt-4")
+ openaiBaseUrl String? // For custom endpoints
+
+ // Fanart.tv (for high-quality images - optional)
+ fanartEnabled Boolean @default(false)
+ fanartApiKey String? // Encrypted
+
+ // === Media Services ===
+ // Audiobookshelf
+ audiobookshelfEnabled Boolean @default(false)
+ audiobookshelfUrl String? @default("http://localhost:13378")
+ audiobookshelfApiKey String? // Encrypted
+
+ // Soulseek (direct connection via slsk-client)
+ soulseekUsername String? // Soulseek network username
+ soulseekPassword String? // Soulseek network password - Encrypted
+
+ // Spotify (for playlist import via URL)
+ spotifyClientId String?
+ spotifyClientSecret String? // Encrypted
+
+ // === Storage Paths ===
+ musicPath String? @default("/music")
+ downloadPath String? @default("/downloads")
+
+ // === Feature Flags ===
+ autoSync Boolean @default(true)
+ autoEnrichMetadata Boolean @default(true)
+
+ // === Advanced Settings ===
+ maxConcurrentDownloads Int @default(3)
+ downloadRetryAttempts Int @default(3)
+ transcodeCacheMaxGb Int @default(10) // Transcode cache size limit in GB
+
+ // === Download Preferences ===
+ // Primary download source: "soulseek" (per-track) or "lidarr" (full albums)
+ downloadSource String @default("soulseek")
+ // When soulseek is primary and fails: "none" (skip) or "lidarr" (download full album)
+ soulseekFallback String @default("none")
+
+ updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+}
+
+model Artist {
+ id String @id @default(cuid())
+ mbid String @unique
+ name String
+ normalizedName String @default("") // Lowercase version for case-insensitive matching
+ summary String? @db.Text
+ heroUrl String?
+ genres Json? // Array of genre strings from Last.fm/MusicBrainz
+ lastSynced DateTime @default(now())
+ lastEnriched DateTime?
+ enrichmentStatus String @default("pending") // pending, enriching, completed, failed
+ searchVector Unsupported("tsvector")?
+
+ albums Album[]
+ similarFrom SimilarArtist[] @relation("FromArtist")
+ similarTo SimilarArtist[] @relation("ToArtist")
+ ownedAlbums OwnedAlbum[]
+
+ @@index([name])
+ @@index([normalizedName])
+ @@index([searchVector], type: Gin)
+}
+
+model Album {
+ id String @id @default(cuid())
+ rgMbid String @unique // release group MBID
+ artistId String
+ title String
+ year Int?
+ coverUrl String?
+ primaryType String // Album, EP, Single, Live, Compilation
+ label String? // Record label (from MusicBrainz)
+ genres Json? // Array of genre strings from Last.fm
+ lastSynced DateTime @default(now())
+ location AlbumLocation @default(LIBRARY) // LIBRARY or DISCOVER
+ searchVector Unsupported("tsvector")?
+
+ artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
+ tracks Track[]
+
+ @@index([artistId])
+ @@index([location])
+ @@index([title])
+ @@index([searchVector], type: Gin)
+}
+
+model Track {
+ id String @id @default(cuid())
+ albumId String
+ title String
+ trackNo Int
+ duration Int // seconds
+ mime String?
+ searchVector Unsupported("tsvector")?
+
+ // Native file system fields (required for self-contained system)
+ filePath String @unique // Relative path: "Artist/Album/track.flac"
+ fileModified DateTime // mtime for change detection
+ fileSize Int // File size in bytes
+
+ // === Audio Analysis (Essentia) ===
+ // Rhythm
+ bpm Float? // Beats per minute (e.g., 120.5)
+ beatsCount Int? // Total beats in track
+
+ // Tonality
+ key String? // Musical key (e.g., "C", "F#", "Bb")
+ keyScale String? // "major" or "minor"
+ keyStrength Float? // Confidence 0-1
+
+ // Energy & Dynamics
+ energy Float? // Overall energy 0-1
+ loudness Float? // Average loudness in dB
+ dynamicRange Float? // Dynamic range in dB
+
+ // Mood & Character (basic/estimated)
+ danceability Float? // 0-1 how suitable for dancing
+ valence Float? // 0 (sad) to 1 (happy) - ML in Enhanced mode
+ arousal Float? // 0 (calm) to 1 (energetic) - ML in Enhanced mode
+
+ // Instrumentation
+ instrumentalness Float? // 0-1 (1 = no vocals) - ML in Enhanced mode
+ acousticness Float? // 0-1 (1 = acoustic)
+ speechiness Float? // 0-1 (1 = spoken word)
+
+ // === Enhanced Mode: ML Mood Predictions ===
+ moodHappy Float? // ML prediction 0-1 (probability of happy)
+ moodSad Float? // ML prediction 0-1 (probability of sad)
+ moodRelaxed Float? // ML prediction 0-1 (probability of relaxed)
+ moodAggressive Float? // ML prediction 0-1 (probability of aggressive)
+ moodParty Float? // ML prediction 0-1 (probability of party/upbeat)
+ moodAcoustic Float? // ML prediction 0-1 (probability of acoustic)
+ moodElectronic Float? // ML prediction 0-1 (probability of electronic)
+ danceabilityMl Float? // ML-based danceability (more accurate than basic)
+
+ // Mood Tags (derived from ML or heuristics)
+ moodTags String[] // ["aggressive", "happy", "sad", "relaxed"]
+
+ // Genre (ML classification from Essentia, backup to Last.fm)
+ essentiaGenres String[] // ["rock", "electronic", "jazz"]
+
+ // Last.fm Tags (user-generated mood/vibe tags)
+ lastfmTags String[] // ["chill", "workout", "sad", "90s"]
+
+ // Analysis Metadata
+ analysisStatus String @default("pending") // pending, processing, completed, failed
+ analysisVersion String? // Essentia version used
+ analysisMode String? // 'standard' or 'enhanced'
+ analyzedAt DateTime?
+ analysisError String? // Error message if failed
+ analysisRetryCount Int @default(0) // Number of retry attempts
+ updatedAt DateTime @updatedAt
+
+ album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
+ plays Play[]
+ playlistItems PlaylistItem[]
+ trackGenres TrackGenre[]
+ likedBy LikedTrack[]
+ cachedBy CachedTrack[]
+ transcodedFiles TranscodedFile[]
+
+ @@index([albumId])
+ @@index([fileModified])
+ @@index([title])
+ @@index([searchVector], type: Gin)
+ @@index([analysisStatus])
+ @@index([bpm])
+ @@index([energy])
+ @@index([valence])
+ @@index([danceability])
+}
+
+// Transcoded file cache for audio streaming
+model TranscodedFile {
+ id String @id @default(cuid())
+ trackId String
+ quality String // original, high, medium, low
+ cachePath String @unique // Relative path in transcode cache
+ cacheSize Int // File size in bytes
+ sourceModified DateTime // For invalidation
+ lastAccessed DateTime @default(now()) // For LRU eviction
+ createdAt DateTime @default(now())
+
+ track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
+
+ @@unique([trackId, quality])
+ @@index([trackId, quality])
+ @@index([lastAccessed])
+}
+
+model Play {
+ id String @id @default(cuid())
+ userId String
+ trackId String
+ playedAt DateTime @default(now())
+ source ListenSource @default(LIBRARY) // LIBRARY, DISCOVERY, or DISCOVERY_KEPT
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
+
+ @@index([userId, playedAt])
+ @@index([trackId])
+ @@index([source])
+}
+
+model Playlist {
+ id String @id @default(cuid())
+ userId String
+ mixId String?
+ name String
+ isPublic Boolean @default(false)
+ createdAt DateTime @default(now())
+
+ // Spotify import metadata
+ spotifyPlaylistId String? // Original Spotify playlist ID
+ spotifyPlaylistUrl String? // Original Spotify URL for re-import
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ items PlaylistItem[]
+ pendingTracks PlaylistPendingTrack[]
+ hiddenByUsers HiddenPlaylist[]
+
+ @@unique([userId, mixId])
+ @@index([userId])
+ @@index([spotifyPlaylistId])
+}
+
+// Track which users have hidden which shared playlists
+model HiddenPlaylist {
+ id String @id @default(cuid())
+ userId String
+ playlistId String
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, playlistId])
+ @@index([userId])
+}
+
+model PlaylistItem {
+ id String @id @default(cuid())
+ playlistId String
+ trackId String
+ sort Int
+
+ playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
+ track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
+
+ @@unique([playlistId, trackId])
+ @@index([playlistId, sort])
+}
+
+// Tracks from Spotify imports that haven't been matched to local library yet
+// These are automatically added to the playlist when the matching track is downloaded
+model PlaylistPendingTrack {
+ id String @id @default(cuid())
+ playlistId String
+ spotifyArtist String // Original artist name from Spotify
+ spotifyTitle String // Original track title from Spotify
+ spotifyAlbum String // Original album name from Spotify
+ albumMbid String? // MusicBrainz album ID if resolved
+ artistMbid String? // MusicBrainz artist ID if resolved
+ deezerPreviewUrl String? // Deezer 30s preview URL for playback while pending
+ sort Int // Position in original playlist
+ createdAt DateTime @default(now())
+
+ playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
+
+ @@unique([playlistId, spotifyArtist, spotifyTitle]) // Prevent duplicates
+ @@index([playlistId])
+ @@index([albumMbid])
+ @@index([artistMbid])
+}
+
+// Spotify Import Jobs - tracks import progress and state
+model SpotifyImportJob {
+ id String @id
+ userId String
+ spotifyPlaylistId String
+ playlistName String
+ status String // pending, downloading, scanning, creating_playlist, completed, failed, cancelled
+ progress Int @default(0) // 0-100
+ albumsTotal Int
+ albumsCompleted Int @default(0)
+ tracksTotal Int
+ tracksMatched Int @default(0)
+ tracksDownloadable Int @default(0)
+ createdPlaylistId String?
+ error String?
+ pendingTracks Json // Array of pending track objects
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+ @@index([status])
+ @@index([createdAt])
+}
+
+model Genre {
+ id String @id @default(cuid())
+ name String @unique
+ trackGenres TrackGenre[]
+}
+
+model TrackGenre {
+ trackId String
+ genreId String
+
+ track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
+ genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
+
+ @@id([trackId, genreId])
+ @@index([genreId])
+}
+
+model SimilarArtist {
+ fromArtistId String
+ toArtistId String
+ weight Float @default(1.0)
+
+ fromArtist Artist @relation("FromArtist", fields: [fromArtistId], references: [id], onDelete: Cascade)
+ toArtist Artist @relation("ToArtist", fields: [toArtistId], references: [id], onDelete: Cascade)
+
+ @@id([fromArtistId, toArtistId])
+ @@index([fromArtistId])
+}
+
+model OwnedAlbum {
+ artistId String
+ rgMbid String
+ source String // lidarr, manual, native_scan
+
+ artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
+
+ @@id([artistId, rgMbid])
+ @@index([artistId])
+}
+
+model DownloadJob {
+ id String @id @default(cuid())
+ correlationId String? @unique // UUID for reliable webhook matching
+ userId String
+ subject String // artist name or album title
+ type String // artist, album
+ targetMbid String // artist MBID or release group MBID
+ status String // pending, processing, completed, failed, exhausted
+ error String? // error message if failed
+ lidarrRef String? // Lidarr's downloadId from webhook
+ lidarrAlbumId Int? // Lidarr's internal album ID for retry/cleanup
+ metadata Json? // additional metadata (downloadType, rootFolderPath, etc.)
+ attempts Int @default(0) // Number of download attempts
+ startedAt DateTime? // When download was initiated (for timeout tracking)
+ completedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ discoveryBatchId String? // Links to Discovery Weekly batch if part of discovery
+
+ // Release iteration tracking (for exhaustive retry)
+ triedReleases String[] @default([]) // GUIDs of releases we've tried
+ releaseIndex Int @default(0) // Current position in release list
+ artistMbid String? // Artist MBID for same-artist fallback
+ cleared Boolean @default(false) // User dismissed from history
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ discoveryBatch DiscoveryBatch? @relation(fields: [discoveryBatchId], references: [id], onDelete: SetNull)
+
+ @@index([userId, status])
+ @@index([status])
+ @@index([discoveryBatchId])
+ @@index([correlationId])
+ @@index([startedAt])
+ @@index([lidarrRef])
+ @@index([artistMbid])
+}
+
+model ListeningState {
+ id String @id @default(cuid())
+ userId String
+ kind String // music, book
+ entityId String // artist/album/book ID
+ trackId String? // current track/chapter
+ positionMs Int @default(0)
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, kind, entityId])
+ @@index([userId])
+}
+
+model DiscoveryAlbum {
+ id String @id @default(cuid())
+ userId String
+ rgMbid String
+ artistName String
+ artistMbid String?
+ albumTitle String
+ lidarrAlbumId Int?
+ downloadedAt DateTime?
+ folderPath String @default("")
+ weekStartDate DateTime // When it was added to discovery
+ weekEndDate DateTime @default(now()) // When it expires
+ status DiscoverStatus @default(ACTIVE)
+ likedAt DateTime? // When user liked it
+ similarity Float? // Similarity score from Last.fm (0-1)
+ tier String? // high, medium, low, wild
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ tracks DiscoveryTrack[]
+
+ @@unique([userId, weekStartDate, rgMbid])
+ @@index([userId, weekStartDate])
+ @@index([downloadedAt])
+ @@index([status])
+}
+
+model DiscoveryTrack {
+ id String @id @default(cuid())
+ discoveryAlbumId String
+ trackId String?
+ fileName String
+ filePath String
+ inPlaylistCount Int @default(0)
+ userKept Boolean @default(false)
+ lastPlayedAt DateTime?
+
+ discoveryAlbum DiscoveryAlbum @relation(fields: [discoveryAlbumId], references: [id], onDelete: Cascade)
+
+ @@index([discoveryAlbumId])
+ @@index([userKept])
+ @@index([lastPlayedAt])
+}
+
+model LikedTrack {
+ userId String
+ trackId String
+ likedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
+
+ @@id([userId, trackId])
+ @@index([userId])
+ @@index([likedAt])
+}
+
+model DislikedEntity {
+ id String @id @default(cuid())
+ userId String
+ entityType String // track, album, artist
+ entityId String // trackId, albumId, artistId
+ dislikedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, entityType, entityId])
+ @@index([userId, entityType])
+}
+
+model CachedTrack {
+ id String @id @default(cuid())
+ userId String
+ trackId String
+ localPath String
+ quality String // original, high, medium, low
+ fileSizeMb Float
+ cachedAt DateTime @default(now())
+ lastAccessedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, trackId, quality])
+ @@index([userId])
+ @@index([lastAccessedAt])
+}
+
+model AudiobookProgress {
+ id String @id @default(cuid())
+ userId String
+ audiobookshelfId String // The audiobook ID from Audiobookshelf
+ title String // Cached for display
+ author String? // Cached for display
+ coverUrl String? // Cached for display
+ currentTime Float @default(0) // Current playback position in seconds
+ duration Float @default(0) // Total duration in seconds
+ isFinished Boolean @default(false)
+ lastPlayedAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ // Note: No foreign key to Audiobook - progress can exist before audiobook is cached
+
+ @@unique([userId, audiobookshelfId])
+ @@index([userId, lastPlayedAt])
+}
+
+// ============================================
+// Cached Audiobooks (from Audiobookshelf)
+// ============================================
+
+model Audiobook {
+ id String @id // Audiobookshelf item ID
+ title String
+ author String?
+ narrator String?
+ description String? @db.Text
+ publishedYear Int?
+ publisher String?
+
+ // Series info
+ series String?
+ seriesSequence String?
+
+ // Media info
+ duration Float? // seconds
+ numTracks Int?
+ numChapters Int?
+ size BigInt? // bytes
+
+ // Metadata
+ isbn String?
+ asin String?
+ language String?
+ genres String[] // array of genres
+ tags String[] // array of tags
+
+ // Files
+ localCoverPath String? // local cached cover image path
+ coverUrl String? // original Audiobookshelf URL
+ audioUrl String // Audiobookshelf streaming URL
+ libraryId String? // Audiobookshelf library ID
+
+ // Timestamps
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ lastSyncedAt DateTime @default(now())
+
+ @@index([title])
+ @@index([author])
+ @@index([series])
+ @@index([lastSyncedAt])
+}
+
+model PodcastRecommendation {
+ id String @id @default(cuid())
+ podcastId String // The source podcast ID (from Audiobookshelf)
+ recommendedId String // Unique ID for the recommended podcast
+ title String
+ author String?
+ description String? @db.Text
+ coverUrl String?
+ episodeCount Int @default(0)
+ feedUrl String?
+ itunesId String?
+ score Float @default(0) // Relevance score
+ cachedAt DateTime @default(now())
+ expiresAt DateTime // When this recommendation expires (30 days from cache)
+
+ @@index([podcastId, expiresAt])
+ @@index([expiresAt]) // For cleanup of expired recommendations
+ @@map("podcast_recommendations")
+}
+
+// ============================================
+// NEW: Independent Podcast System (RSS-based)
+// ============================================
+
+model Podcast {
+ id String @id @default(cuid())
+ feedUrl String @unique
+ title String
+ author String?
+ description String? @db.Text
+ imageUrl String? // Original feed image URL
+ localCoverPath String? // Local cached cover image path
+ itunesId String? @unique
+ language String?
+ explicit Boolean @default(false)
+ episodeCount Int @default(0)
+ lastRefreshed DateTime @default(now())
+ refreshInterval Int @default(3600) // seconds (1 hour default)
+ autoRefresh Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ episodes PodcastEpisode[]
+ subscriptions PodcastSubscription[]
+
+ @@index([itunesId])
+ @@index([lastRefreshed])
+}
+
+model PodcastEpisode {
+ id String @id @default(cuid())
+ podcastId String
+ guid String // RSS GUID (unique per feed)
+ title String
+ description String? @db.Text
+ audioUrl String // Direct MP3/audio URL from RSS
+ duration Int @default(0) // seconds
+ publishedAt DateTime
+ episodeNumber Int?
+ season Int?
+ imageUrl String? // Episode-specific image URL
+ localCoverPath String? // Local cached episode cover
+ fileSize Int? // bytes
+ mimeType String? @default("audio/mpeg")
+ createdAt DateTime @default(now())
+
+ podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
+ progress PodcastProgress[]
+ downloads PodcastDownload[]
+
+ @@unique([podcastId, guid])
+ @@index([podcastId, publishedAt])
+}
+
+// User podcast subscriptions
+model PodcastSubscription {
+ userId String
+ podcastId String
+ subscribedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
+
+ @@id([userId, podcastId])
+ @@index([userId])
+ @@index([podcastId])
+}
+
+// Listening progress for podcast episodes
+model PodcastProgress {
+ id String @id @default(cuid())
+ userId String
+ episodeId String
+ currentTime Float @default(0) // seconds
+ duration Float @default(0) // seconds
+ isFinished Boolean @default(false)
+ lastPlayedAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, episodeId])
+ @@index([userId, lastPlayedAt])
+}
+
+// Downloaded episodes for offline playback
+model PodcastDownload {
+ id String @id @default(cuid())
+ userId String
+ episodeId String
+ localPath String // Where the file is stored locally
+ fileSizeMb Float
+ downloadedAt DateTime @default(now())
+ lastAccessedAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, episodeId])
+ @@index([userId])
+ @@index([lastAccessedAt]) // For cache cleanup
+}
+
+// ============================================
+// Discover Weekly System
+// ============================================
+
+// Album exclusion tracking - prevents suggesting the same album for 6 months
+model DiscoverExclusion {
+ id String @id @default(cuid())
+ userId String
+ albumMbid String // MusicBrainz release group ID
+ artistName String? // For display purposes
+ albumTitle String? // For display purposes
+ lastSuggestedAt DateTime @default(now())
+ expiresAt DateTime // 6 months from lastSuggestedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, albumMbid])
+ @@index([userId, expiresAt])
+}
+
+// User configuration for Discover Weekly
+model UserDiscoverConfig {
+ id String @id @default(cuid())
+ userId String @unique
+ playlistSize Int @default(40) // 5-50, increments of 5
+ maxRetryAttempts Int @default(3) // 1-10, how many times to retry finding replacements
+ exclusionMonths Int @default(6) // 0-12, months to exclude albums after download (0 = no exclusion)
+ downloadRatio Float @default(1.3) // 1.0-2.0, multiplier for albums to request vs target songs
+ enabled Boolean @default(true)
+ lastGeneratedAt DateTime?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
+
+// Unavailable albums (recommended but not found in Lidarr)
+model UnavailableAlbum {
+ id String @id @default(cuid())
+ userId String
+ artistName String
+ albumTitle String
+ albumMbid String
+ artistMbid String?
+ similarity Float // Similarity score from Last.fm (0-1)
+ tier String // high, medium, low, wild
+ weekStartDate DateTime // When it was recommended
+ previewUrl String? // 30-second preview from Deezer
+ deezerTrackId String? // Deezer track ID for preview
+ deezerAlbumId String? // Deezer album ID for preview
+ attemptNumber Int @default(0) // 0 = original, 1 = first replacement, 2 = second replacement, etc.
+ originalAlbumId String? // References the original album's ID if this is a replacement
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, weekStartDate, albumMbid])
+ @@index([userId, weekStartDate])
+ @@index([userId, weekStartDate, attemptNumber])
+ @@index([originalAlbumId])
+}
+
+// Batch tracking for Discovery Weekly generation
+model DiscoveryBatch {
+ id String @id @default(cuid())
+ userId String
+ weekStart DateTime // Week this batch is for
+ targetSongCount Int // Target number of songs to find
+ status String @default("downloading") // downloading, scanning, completed, failed
+ totalAlbums Int @default(0) // Total albums queued for download
+ completedAlbums Int @default(0) // Albums successfully downloaded
+ failedAlbums Int @default(0) // Albums that failed to download
+ finalSongCount Int @default(0) // Final number of songs in playlist
+ logs Json? // Structured logs for debugging [{timestamp, level, message}]
+ errorMessage String? // Summary error message if failed
+ createdAt DateTime @default(now())
+ completedAt DateTime?
+
+ jobs DownloadJob[]
+
+ @@index([userId, weekStart])
+ @@index([status])
+ @@index([createdAt])
+}
+
+// ============================================
+// API Keys for Mobile/External Authentication
+// ============================================
+
+model ApiKey {
+ id String @id @default(cuid())
+ userId String
+ key String @unique // 64-character hex string
+ name String // Device name: "iPhone 14", "Android Tablet"
+ lastUsed DateTime @default(now())
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([key])
+ @@index([userId])
+ @@index([lastUsed])
+}
+
+// Temporary device link codes for QR login
+model DeviceLinkCode {
+ id String @id @default(cuid())
+ code String @unique // 6-digit alphanumeric code
+ userId String // User who generated this code
+ expiresAt DateTime // 5 minutes from creation
+ usedAt DateTime? // When the code was used
+ deviceName String? // Name of device that used the code
+ apiKeyId String? // The API key created when code was used
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([code, expiresAt])
+ @@index([userId])
+}
+
+// ============================================
+// Enums
+// ============================================
+
+enum DiscoverStatus {
+ ACTIVE // Currently in discover folder
+ LIKED // User liked, will be moved to permanent
+ MOVED // Already moved to permanent library
+ DELETED // Week ended, was not liked
+}
+
+enum ListenSource {
+ LIBRARY // From permanent /music folder
+ DISCOVERY // From /music/Discover, not yet liked
+ DISCOVERY_KEPT // Was discovery, user liked it
+}
+
+enum AlbumLocation {
+ LIBRARY // In /music
+ DISCOVER // In /music/Discover
+}
+
+// ============================================
+// Notifications System
+// ============================================
+
+model Notification {
+ id String @id @default(cuid())
+ userId String
+ type String // system, download_complete, playlist_ready, error, import_complete
+ title String
+ message String?
+ metadata Json? // { playlistId, albumId, artistId, etc. }
+ read Boolean @default(false)
+ cleared Boolean @default(false)
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, cleared])
+ @@index([userId, read])
+ @@index([createdAt])
+}
diff --git a/backend/seeds/createUser.ts b/backend/seeds/createUser.ts
new file mode 100644
index 0000000..55e919b
--- /dev/null
+++ b/backend/seeds/createUser.ts
@@ -0,0 +1,49 @@
+import bcrypt from "bcrypt";
+import { PrismaClient } from "@prisma/client";
+import * as readline from "readline";
+
+const prisma = new PrismaClient();
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+});
+
+function prompt(question: string): Promise {
+ return new Promise((resolve) => {
+ rl.question(question, (answer) => resolve(answer));
+ });
+}
+
+async function main() {
+ const username = await prompt("Username: ");
+ const password = await prompt("Password: ");
+ const role = await prompt("Role (user/admin) [user]: ");
+
+ if (!username || !password) {
+ console.error("Username and password required");
+ process.exit(1);
+ }
+
+ const passwordHash = await bcrypt.hash(password, 10);
+
+ const user = await prisma.user.create({
+ data: {
+ username,
+ passwordHash,
+ role: role || "user",
+ },
+ });
+
+ console.log(`\nCreated user: ${user.username} (${user.role})`);
+ rl.close();
+}
+
+main()
+ .catch((e) => {
+ console.error(e);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/backend/src/config.ts b/backend/src/config.ts
new file mode 100644
index 0000000..e77e590
--- /dev/null
+++ b/backend/src/config.ts
@@ -0,0 +1,110 @@
+import dotenv from "dotenv";
+import { z } from "zod";
+import { validateMusicConfig, MusicConfig } from "./utils/configValidator";
+
+dotenv.config();
+
+// Validate critical environment variables on startup
+const envSchema = z.object({
+ DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
+ REDIS_URL: z.string().min(1, "REDIS_URL is required"),
+ SESSION_SECRET: z
+ .string()
+ .min(32, "SESSION_SECRET must be at least 32 characters"),
+ PORT: z.string().optional(),
+ NODE_ENV: z.enum(["development", "production", "test"]).optional(),
+ MUSIC_PATH: z.string().min(1, "MUSIC_PATH is required"),
+});
+
+try {
+ envSchema.parse(process.env);
+ console.log("Environment variables validated");
+} catch (error) {
+ if (error instanceof z.ZodError) {
+ console.error(" Environment validation failed:");
+ error.errors.forEach((err) => {
+ console.error(` - ${err.path.join(".")}: ${err.message}`);
+ });
+ console.error(
+ "\n Please check your .env file and ensure all required variables are set."
+ );
+ process.exit(1);
+ }
+}
+
+// Music config - will be initialized async
+let musicConfig: MusicConfig = {
+ musicPath: process.env.MUSIC_PATH || "/music",
+ transcodeCachePath:
+ process.env.TRANSCODE_CACHE_PATH || "./cache/transcodes",
+ transcodeCacheMaxGb: parseInt(
+ process.env.TRANSCODE_CACHE_MAX_GB || "10",
+ 10
+ ),
+};
+
+// Initialize music configuration asynchronously
+export async function initializeMusicConfig() {
+ try {
+ musicConfig = await validateMusicConfig();
+ console.log("Music configuration initialized");
+ } catch (err: any) {
+ console.error(" Configuration validation failed:", err.message);
+ console.warn(" Using default/environment configuration");
+ // Don't exit process - allow app to start for other features
+ // Music features will fail gracefully if config is invalid
+ }
+}
+
+export const config = {
+ port: parseInt(process.env.PORT || "3006", 10),
+ nodeEnv: process.env.NODE_ENV || "development",
+ // DATABASE_URL and REDIS_URL are validated by envSchema above, so they're guaranteed to exist
+ databaseUrl: process.env.DATABASE_URL!,
+ redisUrl: process.env.REDIS_URL!,
+ sessionSecret: process.env.SESSION_SECRET!,
+
+ // Music library configuration (self-contained native music system)
+ // Access via config.music - will be updated after initialization
+ get music() {
+ return musicConfig;
+ },
+
+ // Lidarr - now reads from database via lidarrService.ensureInitialized()
+ lidarr:
+ process.env.LIDARR_ENABLED === "true"
+ ? {
+ url: process.env.LIDARR_URL!,
+ apiKey: process.env.LIDARR_API_KEY!,
+ enabled: true,
+ }
+ : undefined,
+
+ // Last.fm - ships with default app key, users can override in settings
+ lastfm: {
+ // Default application API key (free tier, for public use)
+ // Users can override this in System Settings with their own key
+ apiKey: process.env.LASTFM_API_KEY || "c1797de6bf0b7e401b623118120cd9e1",
+ },
+
+ // OpenAI - reads from database
+ openai: {
+ apiKey: process.env.OPENAI_API_KEY || "", // Fallback to DB
+ },
+
+ // Deezer - reads from database
+ deezer: {
+ apiKey: process.env.DEEZER_API_KEY || "", // Fallback to DB
+ },
+
+ audiobookshelf: process.env.AUDIOBOOKSHELF_URL
+ ? {
+ url: process.env.AUDIOBOOKSHELF_URL,
+ token: process.env.AUDIOBOOKSHELF_TOKEN!,
+ }
+ : undefined,
+
+ allowedOrigins:
+ process.env.ALLOWED_ORIGINS?.split(",").map((o) => o.trim()) ||
+ (process.env.NODE_ENV === "development" ? true : []),
+};
diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts
new file mode 100644
index 0000000..2cd938d
--- /dev/null
+++ b/backend/src/config/swagger.ts
@@ -0,0 +1,103 @@
+import swaggerJsdoc from "swagger-jsdoc";
+import { config } from "../config";
+
+const options: swaggerJsdoc.Options = {
+ definition: {
+ openapi: "3.0.0",
+ info: {
+ title: "Lidify API",
+ version: "1.0.0",
+ description:
+ "Self-hosted music streaming server with Discover Weekly and full-text search",
+ contact: {
+ name: "Lidify",
+ url: "https://github.com/Chevron7Locked/lidify",
+ },
+ },
+ servers: [
+ {
+ url: `http://localhost:${config.port}`,
+ description: "Development server",
+ },
+ ],
+ components: {
+ securitySchemes: {
+ sessionAuth: {
+ type: "apiKey",
+ in: "cookie",
+ name: "connect.sid",
+ description: "Session cookie authentication (web UI)",
+ },
+ apiKeyAuth: {
+ type: "apiKey",
+ in: "header",
+ name: "X-API-Key",
+ description: "API key authentication (mobile apps)",
+ },
+ },
+ schemas: {
+ User: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ username: { type: "string" },
+ role: { type: "string", enum: ["user", "admin"] },
+ createdAt: { type: "string", format: "date-time" },
+ },
+ },
+ Artist: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ mbid: { type: "string" },
+ name: { type: "string" },
+ heroUrl: { type: "string", nullable: true },
+ summary: { type: "string", nullable: true },
+ },
+ },
+ Album: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ rgMbid: { type: "string" },
+ artistId: { type: "string" },
+ title: { type: "string" },
+ year: { type: "integer", nullable: true },
+ coverUrl: { type: "string", nullable: true },
+ primaryType: { type: "string" },
+ },
+ },
+ Track: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ albumId: { type: "string" },
+ title: { type: "string" },
+ trackNo: { type: "integer" },
+ duration: { type: "integer" },
+ filePath: { type: "string" },
+ },
+ },
+ ApiKey: {
+ type: "object",
+ properties: {
+ id: { type: "string" },
+ name: { type: "string" },
+ lastUsed: { type: "string", format: "date-time" },
+ createdAt: { type: "string", format: "date-time" },
+ },
+ },
+ Error: {
+ type: "object",
+ properties: {
+ error: { type: "string" },
+ },
+ },
+ },
+ },
+ security: [{ sessionAuth: [] }, { apiKeyAuth: [] }],
+ },
+ apis: ["./src/routes/*.ts", "./src/config/swaggerSchemas.ts"],
+};
+
+export const swaggerSpec = swaggerJsdoc(options);
diff --git a/backend/src/index.ts b/backend/src/index.ts
new file mode 100644
index 0000000..c44602c
--- /dev/null
+++ b/backend/src/index.ts
@@ -0,0 +1,300 @@
+import express from "express";
+import session from "express-session";
+import RedisStore from "connect-redis";
+import cors from "cors";
+import helmet from "helmet";
+import { config } from "./config";
+import { redisClient } from "./utils/redis";
+import { prisma } from "./utils/db";
+
+import authRoutes from "./routes/auth";
+import onboardingRoutes from "./routes/onboarding";
+import libraryRoutes from "./routes/library";
+import playsRoutes from "./routes/plays";
+import settingsRoutes from "./routes/settings";
+import systemSettingsRoutes from "./routes/systemSettings";
+import listeningStateRoutes from "./routes/listeningState";
+import playbackStateRoutes from "./routes/playbackState";
+import offlineRoutes from "./routes/offline";
+import playlistsRoutes from "./routes/playlists";
+import searchRoutes from "./routes/search";
+import recommendationsRoutes from "./routes/recommendations";
+import downloadsRoutes from "./routes/downloads";
+import webhooksRoutes from "./routes/webhooks";
+import audiobooksRoutes from "./routes/audiobooks";
+import podcastsRoutes from "./routes/podcasts";
+import artistsRoutes from "./routes/artists";
+import soulseekRoutes from "./routes/soulseek";
+import discoverRoutes from "./routes/discover";
+import apiKeysRoutes from "./routes/apiKeys";
+import mixesRoutes from "./routes/mixes";
+import enrichmentRoutes from "./routes/enrichment";
+import homepageRoutes from "./routes/homepage";
+import deviceLinkRoutes from "./routes/deviceLink";
+import spotifyRoutes from "./routes/spotify";
+import notificationsRoutes from "./routes/notifications";
+import browseRoutes from "./routes/browse";
+import analysisRoutes from "./routes/analysis";
+import releasesRoutes from "./routes/releases";
+import { dataCacheService } from "./services/dataCache";
+import { errorHandler } from "./middleware/errorHandler";
+import {
+ authLimiter,
+ apiLimiter,
+ streamLimiter,
+ imageLimiter,
+} from "./middleware/rateLimiter";
+import swaggerUi from "swagger-ui-express";
+import { swaggerSpec } from "./config/swagger";
+
+const app = express();
+
+// Middleware
+app.use(
+ helmet({
+ crossOriginResourcePolicy: { policy: "cross-origin" },
+ })
+);
+app.use(
+ cors({
+ origin: (origin, callback) => {
+ // For self-hosted apps: allow all origins by default
+ // Users deploy on their own domains/IPs - we can't predict them
+ // Security is handled by authentication, not CORS
+ if (!origin) {
+ // Allow requests with no origin (same-origin, curl, etc.)
+ callback(null, true);
+ } else if (
+ config.allowedOrigins === true ||
+ config.nodeEnv === "development"
+ ) {
+ // Explicitly allow all origins
+ callback(null, true);
+ } else if (
+ Array.isArray(config.allowedOrigins) &&
+ config.allowedOrigins.length > 0
+ ) {
+ // Check against specific allowed origins if configured
+ if (config.allowedOrigins.includes(origin)) {
+ callback(null, true);
+ } else {
+ // For self-hosted: allow anyway but log it
+ // Users shouldn't have to configure CORS for their own app
+ console.log(
+ `[CORS] Origin ${origin} not in allowlist, allowing anyway (self-hosted)`
+ );
+ callback(null, true);
+ }
+ } else {
+ // No restrictions - allow all (self-hosted default)
+ callback(null, true);
+ }
+ },
+ credentials: true,
+ })
+);
+app.use(express.json({ limit: "1mb" })); // Increased from 100KB default to support large queue payloads
+
+// Session
+// Trust proxy for reverse proxy setups (nginx, traefik, etc.)
+app.set("trust proxy", 1);
+
+app.use(
+ session({
+ store: new RedisStore({
+ client: redisClient,
+ ttl: 7 * 24 * 60 * 60, // 7 days in seconds - must match cookie maxAge
+ }),
+ secret: config.sessionSecret,
+ resave: false,
+ saveUninitialized: false,
+ proxy: true, // Trust the reverse proxy
+ cookie: {
+ httpOnly: true,
+ // For self-hosted apps: allow HTTP access (common for LAN deployments)
+ // If behind HTTPS reverse proxy, the proxy should handle security
+ secure: false,
+ sameSite: "lax",
+ maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
+ },
+ })
+);
+
+// Routes - All API routes prefixed with /api for clear separation from frontend
+// Apply rate limiting to auth routes
+app.use("/api/auth/login", authLimiter);
+app.use("/api/auth/register", authLimiter);
+app.use("/api/auth", authRoutes);
+app.use("/api/onboarding", onboardingRoutes); // Public onboarding routes
+
+// Apply general API rate limiting to all API routes
+app.use("/api/api-keys", apiLimiter, apiKeysRoutes);
+app.use("/api/device-link", apiLimiter, deviceLinkRoutes);
+// NOTE: /api/library has its own rate limiting (imageLimiter for cover-art, apiLimiter for others)
+app.use("/api/library", libraryRoutes);
+app.use("/api/plays", apiLimiter, playsRoutes);
+app.use("/api/settings", apiLimiter, settingsRoutes);
+app.use("/api/system-settings", apiLimiter, systemSettingsRoutes);
+app.use("/api/listening-state", apiLimiter, listeningStateRoutes);
+app.use("/api/playback-state", playbackStateRoutes); // No rate limit - syncs frequently
+app.use("/api/offline", apiLimiter, offlineRoutes);
+app.use("/api/playlists", apiLimiter, playlistsRoutes);
+app.use("/api/search", apiLimiter, searchRoutes);
+app.use("/api/recommendations", apiLimiter, recommendationsRoutes);
+app.use("/api/downloads", apiLimiter, downloadsRoutes);
+app.use("/api/notifications", apiLimiter, notificationsRoutes);
+app.use("/api/webhooks", webhooksRoutes); // Webhooks should not be rate limited
+// NOTE: /api/audiobooks has its own rate limiting (imageLimiter for covers, apiLimiter for others)
+app.use("/api/audiobooks", audiobooksRoutes);
+app.use("/api/podcasts", apiLimiter, podcastsRoutes);
+app.use("/api/artists", apiLimiter, artistsRoutes);
+app.use("/api/soulseek", apiLimiter, soulseekRoutes);
+app.use("/api/discover", apiLimiter, discoverRoutes);
+app.use("/api/mixes", apiLimiter, mixesRoutes);
+app.use("/api/enrichment", apiLimiter, enrichmentRoutes);
+app.use("/api/homepage", apiLimiter, homepageRoutes);
+app.use("/api/spotify", apiLimiter, spotifyRoutes);
+app.use("/api/browse", apiLimiter, browseRoutes);
+app.use("/api/analysis", apiLimiter, analysisRoutes);
+app.use("/api/releases", apiLimiter, releasesRoutes);
+
+// Health check (keep at root for simple container health checks)
+app.get("/health", (req, res) => {
+ res.json({ status: "ok" });
+});
+app.get("/api/health", (req, res) => {
+ res.json({ status: "ok" });
+});
+
+// Swagger API Documentation
+app.use(
+ "/api/docs",
+ swaggerUi.serve,
+ swaggerUi.setup(swaggerSpec, {
+ customCss: ".swagger-ui .topbar { display: none }",
+ customSiteTitle: "Lidify API Documentation",
+ })
+);
+
+// Serve raw OpenAPI spec
+app.get("/api/docs.json", (req, res) => {
+ res.json(swaggerSpec);
+});
+
+// Error handler
+app.use(errorHandler);
+
+app.listen(config.port, "0.0.0.0", async () => {
+ console.log(
+ `Lidify API running on port ${config.port} (accessible on all network interfaces)`
+ );
+
+ // Enable slow query monitoring in development
+ if (config.nodeEnv === "development") {
+ const { enableSlowQueryMonitoring } = await import(
+ "./utils/queryMonitor"
+ );
+ enableSlowQueryMonitoring();
+ }
+
+ // Initialize music configuration (reads from SystemSettings)
+ const { initializeMusicConfig } = await import("./config");
+ await initializeMusicConfig();
+
+ // Initialize Bull queue workers
+ await import("./workers");
+
+ // Set up Bull Board dashboard
+ const { createBullBoard } = await import("@bull-board/api");
+ const { BullAdapter } = await import("@bull-board/api/bullAdapter");
+ const { ExpressAdapter } = await import("@bull-board/express");
+ const { scanQueue, discoverQueue, imageQueue } = await import(
+ "./workers/queues"
+ );
+
+ const serverAdapter = new ExpressAdapter();
+ serverAdapter.setBasePath("/api/admin/queues");
+
+ createBullBoard({
+ queues: [
+ new BullAdapter(scanQueue),
+ new BullAdapter(discoverQueue),
+ new BullAdapter(imageQueue),
+ ],
+ serverAdapter,
+ });
+
+ app.use("/api/admin/queues", serverAdapter.getRouter());
+ console.log("Bull Board dashboard available at /api/admin/queues");
+
+ // Note: Native library scanning is now triggered manually via POST /library/scan
+ // No automatic sync on startup - user must manually scan their music folder
+
+ // Enrichment worker enabled for OWNED content only
+ // - Background enrichment: Genres, MBIDs, similar artists for owned albums/artists
+ // - On-demand fetching: Artist images, bios when browsing (cached in Redis 7 days)
+ console.log(
+ "Background enrichment enabled for owned content (genres, MBIDs, etc.)"
+ );
+
+ // Warm up Redis cache from database on startup
+ // This populates Redis with existing artist images and album covers
+ // so first page loads are instant instead of waiting for cache population
+ dataCacheService.warmupCache().catch((err) => {
+ console.error("Cache warmup failed:", err);
+ });
+
+ // Podcast cache cleanup - runs daily to remove cached episodes older than 30 days
+ const { cleanupExpiredCache } = await import("./services/podcastDownload");
+
+ // Run cleanup on startup (async, don't block)
+ cleanupExpiredCache().catch((err) => {
+ console.error("Podcast cache cleanup failed:", err);
+ });
+
+ // Schedule daily cleanup (every 24 hours)
+ const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
+ setInterval(() => {
+ cleanupExpiredCache().catch((err) => {
+ console.error("Scheduled podcast cache cleanup failed:", err);
+ });
+ }, TWENTY_FOUR_HOURS);
+ console.log("Podcast cache cleanup scheduled (daily, 30-day expiry)");
+});
+
+// Graceful shutdown handling
+let isShuttingDown = false;
+
+async function gracefulShutdown(signal: string) {
+ if (isShuttingDown) {
+ console.log("Shutdown already in progress...");
+ return;
+ }
+
+ isShuttingDown = true;
+ console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
+
+ try {
+ // Shutdown workers (intervals, crons, queues)
+ const { shutdownWorkers } = await import("./workers");
+ await shutdownWorkers();
+
+ // Close Redis connection
+ console.log("Closing Redis connection...");
+ await redisClient.quit();
+
+ // Close Prisma connection
+ console.log("Closing database connection...");
+ await prisma.$disconnect();
+
+ console.log("Graceful shutdown complete");
+ process.exit(0);
+ } catch (error) {
+ console.error("Error during shutdown:", error);
+ process.exit(1);
+ }
+}
+
+// Handle termination signals
+process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
+process.on("SIGINT", () => gracefulShutdown("SIGINT"));
diff --git a/backend/src/jobs/queueCleaner.ts b/backend/src/jobs/queueCleaner.ts
new file mode 100644
index 0000000..672fcc9
--- /dev/null
+++ b/backend/src/jobs/queueCleaner.ts
@@ -0,0 +1,320 @@
+import { prisma } from "../utils/db";
+import { getSystemSettings } from "../utils/systemSettings";
+import {
+ cleanStuckDownloads,
+ getRecentCompletedDownloads,
+} from "../services/lidarr";
+import { scanQueue } from "../workers/queues";
+import { simpleDownloadManager } from "../services/simpleDownloadManager";
+
+class QueueCleanerService {
+ private isRunning = false;
+ private checkInterval = 30000; // 30 seconds when active
+ private emptyQueueChecks = 0;
+ private maxEmptyChecks = 3; // Stop after 3 consecutive empty checks
+ private timeoutId?: NodeJS.Timeout;
+
+ /**
+ * Start the polling loop
+ * Safe to call multiple times - won't create duplicate loops
+ */
+ async start() {
+ if (this.isRunning) {
+ console.log(" Queue cleaner already running");
+ return;
+ }
+
+ this.isRunning = true;
+ this.emptyQueueChecks = 0;
+ console.log(" Queue cleaner started (checking every 30s)");
+
+ await this.runCleanup();
+ }
+
+ /**
+ * Stop the polling loop
+ */
+ stop() {
+ if (this.timeoutId) {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+ this.isRunning = false;
+ console.log(" Queue cleaner stopped (queue empty)");
+ }
+
+ /**
+ * Main cleanup logic - runs every 30 seconds when active
+ */
+ private async runCleanup() {
+ if (!this.isRunning) return;
+
+ try {
+ // Use getSystemSettings() to get decrypted API key
+ const settings = await getSystemSettings();
+
+ if (!settings?.lidarrUrl || !settings?.lidarrApiKey) {
+ console.log(" Lidarr not configured, stopping queue cleaner");
+ this.stop();
+ return;
+ }
+
+ // PART 0: Check for stale downloads (timed out)
+ const staleCount =
+ await simpleDownloadManager.markStaleJobsAsFailed();
+ if (staleCount > 0) {
+ console.log(`⏰ Cleaned up ${staleCount} stale download(s)`);
+ this.emptyQueueChecks = 0; // Reset counter
+ }
+
+ // PART 0.25: Reconcile processing jobs with Lidarr (fix missed webhooks)
+ const reconcileResult =
+ await simpleDownloadManager.reconcileWithLidarr();
+ if (reconcileResult.reconciled > 0) {
+ console.log(
+ `✓ Reconciled ${reconcileResult.reconciled} job(s) with Lidarr`
+ );
+ this.emptyQueueChecks = 0; // Reset counter
+ }
+
+ // PART 0.5: Check for stuck discovery batches (batch-level timeout)
+ const { discoverWeeklyService } = await import(
+ "../services/discoverWeekly"
+ );
+ const stuckBatchCount =
+ await discoverWeeklyService.checkStuckBatches();
+ if (stuckBatchCount > 0) {
+ console.log(
+ `⏰ Force-completed ${stuckBatchCount} stuck discovery batch(es)`
+ );
+ this.emptyQueueChecks = 0; // Reset counter
+ }
+
+ // PART 1: Check for stuck downloads needing blocklist + retry
+ const cleanResult = await cleanStuckDownloads(
+ settings.lidarrUrl,
+ settings.lidarrApiKey
+ );
+
+ if (cleanResult.removed > 0) {
+ console.log(
+ `[CLEANUP] Removed ${cleanResult.removed} stuck download(s) - searching for alternatives`
+ );
+ this.emptyQueueChecks = 0; // Reset counter - queue had activity
+
+ // Update retry count for jobs that might match these titles
+ // Note: This is a best-effort match since we only have the title
+ for (const title of cleanResult.items) {
+ // Try to extract artist and album from the title
+ // Typical format: "Artist - Album" or "Artist - Album (Year)"
+ const parts = title.split(" - ");
+ if (parts.length >= 2) {
+ const artistName = parts[0].trim();
+ const albumPart = parts.slice(1).join(" - ").trim();
+ // Remove year in parentheses if present
+ const albumTitle = albumPart
+ .replace(/\s*\(\d{4}\)\s*$/, "")
+ .trim();
+
+ // Find matching processing jobs
+ const matchingJobs = await prisma.downloadJob.findMany({
+ where: {
+ status: "processing",
+ subject: {
+ contains: albumTitle,
+ mode: "insensitive",
+ },
+ },
+ });
+
+ for (const job of matchingJobs) {
+ const metadata = (job.metadata as any) || {};
+ const currentRetryCount = metadata.retryCount || 0;
+
+ await prisma.downloadJob.update({
+ where: { id: job.id },
+ data: {
+ metadata: {
+ ...metadata,
+ retryCount: currentRetryCount + 1,
+ lastError:
+ "Import failed - searching for alternative release",
+ },
+ },
+ });
+
+ console.log(
+ ` Updated job ${job.id}: retry ${
+ currentRetryCount + 1
+ }`
+ );
+ }
+ }
+ }
+ }
+
+ // PART 2: Check for completed downloads (missing webhooks)
+ const completedDownloads = await getRecentCompletedDownloads(
+ settings.lidarrUrl,
+ settings.lidarrApiKey,
+ 5 // Only check last 5 minutes since we're running frequently
+ );
+
+ let recoveredCount = 0;
+ let skippedCount = 0;
+
+ for (const download of completedDownloads) {
+ // Skip records without album data (can happen with certain event types)
+ if (!download.album?.foreignAlbumId) {
+ skippedCount++;
+ continue;
+ }
+
+ const mbid = download.album.foreignAlbumId;
+
+ // Find matching job(s) in database by MBID or downloadId
+ const orphanedJobs = await prisma.downloadJob.findMany({
+ where: {
+ status: { in: ["processing", "pending"] },
+ OR: [
+ { targetMbid: mbid },
+ { lidarrRef: download.downloadId },
+ ],
+ },
+ });
+
+ if (orphanedJobs.length > 0) {
+ const artistName =
+ download.artist?.name || "Unknown Artist";
+ const albumTitle = download.album?.title || "Unknown Album";
+ console.log(
+ `Recovered orphaned job: ${artistName} - ${albumTitle}`
+ );
+ console.log(` Download ID: ${download.downloadId}`);
+ this.emptyQueueChecks = 0; // Reset counter - found work to do
+ recoveredCount += orphanedJobs.length;
+
+ // Mark all matching jobs as complete
+ await prisma.downloadJob.updateMany({
+ where: {
+ id: {
+ in: orphanedJobs.map(
+ (j: { id: string }) => j.id
+ ),
+ },
+ },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ },
+ });
+
+ // Check batch completion for any Discovery jobs
+ // Use proper checkBatchCompletion() instead of manual logic
+ const discoveryBatchIds = new Set();
+ for (const job of orphanedJobs) {
+ if (job.discoveryBatchId) {
+ discoveryBatchIds.add(job.discoveryBatchId);
+ }
+ }
+
+ if (discoveryBatchIds.size > 0) {
+ const { discoverWeeklyService } = await import(
+ "../services/discoverWeekly"
+ );
+ for (const batchId of discoveryBatchIds) {
+ console.log(
+ ` Checking Discovery batch completion: ${batchId}`
+ );
+ await discoverWeeklyService.checkBatchCompletion(
+ batchId
+ );
+ }
+ }
+
+ // Trigger library scan for non-discovery jobs
+ const nonDiscoveryJobs = orphanedJobs.filter(
+ (j: { discoveryBatchId: string | null }) =>
+ !j.discoveryBatchId
+ );
+ if (nonDiscoveryJobs.length > 0) {
+ console.log(
+ ` Triggering library scan for recovered job(s)...`
+ );
+ await scanQueue.add("scan", {
+ type: "full",
+ source: "queue-cleaner-recovery",
+ });
+ }
+ }
+ }
+
+ if (recoveredCount > 0) {
+ console.log(`Recovered ${recoveredCount} orphaned job(s)`);
+ }
+
+ // Only log skipped count occasionally to reduce noise
+ if (skippedCount > 0 && this.emptyQueueChecks === 0) {
+ console.log(
+ ` (Skipped ${skippedCount} incomplete download records)`
+ );
+ }
+
+ // PART 3: Check if we should stop (no activity)
+ const activeJobs = await prisma.downloadJob.count({
+ where: {
+ status: { in: ["pending", "processing"] },
+ },
+ });
+
+ const hadActivity =
+ cleanResult.removed > 0 || recoveredCount > 0 || activeJobs > 0;
+
+ if (!hadActivity) {
+ this.emptyQueueChecks++;
+ console.log(
+ ` Queue empty (${this.emptyQueueChecks}/${this.maxEmptyChecks})`
+ );
+
+ if (this.emptyQueueChecks >= this.maxEmptyChecks) {
+ console.log(
+ ` No activity for ${this.maxEmptyChecks} checks - stopping cleaner`
+ );
+ this.stop();
+ return;
+ }
+ } else {
+ this.emptyQueueChecks = 0;
+ }
+
+ // Schedule next check
+ this.timeoutId = setTimeout(
+ () => this.runCleanup(),
+ this.checkInterval
+ );
+ } catch (error) {
+ console.error(" Queue cleanup error:", error);
+ // Still schedule next check even on error
+ this.timeoutId = setTimeout(
+ () => this.runCleanup(),
+ this.checkInterval
+ );
+ }
+ }
+
+ /**
+ * Get current status (for debugging/monitoring)
+ */
+ getStatus() {
+ return {
+ isRunning: this.isRunning,
+ emptyQueueChecks: this.emptyQueueChecks,
+ nextCheckIn: this.isRunning
+ ? `${this.checkInterval / 1000}s`
+ : "stopped",
+ };
+ }
+}
+
+// Export singleton instance
+export const queueCleaner = new QueueCleanerService();
diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts
new file mode 100644
index 0000000..44dd4f1
--- /dev/null
+++ b/backend/src/middleware/auth.ts
@@ -0,0 +1,205 @@
+import { Request, Response, NextFunction } from "express";
+import { prisma } from "../utils/db";
+import jwt from "jsonwebtoken";
+
+// JWT_SECRET is required - SESSION_SECRET is used as fallback since docker-entrypoint.sh generates it
+const JWT_SECRET = process.env.JWT_SECRET || process.env.SESSION_SECRET;
+
+if (!JWT_SECRET) {
+ throw new Error(
+ "JWT_SECRET or SESSION_SECRET environment variable is required for authentication"
+ );
+}
+
+declare global {
+ namespace Express {
+ interface Request {
+ user?: {
+ id: string;
+ username: string;
+ role: string;
+ };
+ }
+ }
+}
+
+export interface JWTPayload {
+ userId: string;
+ username: string;
+ role: string;
+}
+
+export function generateToken(user: { id: string; username: string; role: string }): string {
+ return jwt.sign(
+ { userId: user.id, username: user.username, role: user.role },
+ JWT_SECRET,
+ { expiresIn: "30d" }
+ );
+}
+
+export async function requireAuth(
+ req: Request,
+ res: Response,
+ next: NextFunction
+) {
+ // First, check session-based auth (primary method)
+ if (req.session?.userId) {
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: req.session.userId },
+ select: { id: true, username: true, role: true },
+ });
+
+ if (user) {
+ req.user = user;
+ return next();
+ }
+ } catch (error) {
+ console.error("Session auth error:", error);
+ }
+ }
+
+ // Check for API key in X-API-Key header (for mobile/external apps)
+ const apiKey = req.headers["x-api-key"] as string;
+ if (apiKey) {
+ try {
+ const apiKeyRecord = await prisma.apiKey.findUnique({
+ where: { key: apiKey },
+ include: { user: { select: { id: true, username: true, role: true } } },
+ });
+
+ if (apiKeyRecord && apiKeyRecord.user) {
+ // Update last used timestamp (async, don't block)
+ prisma.apiKey.update({
+ where: { id: apiKeyRecord.id },
+ data: { lastUsed: new Date() },
+ }).catch(() => {}); // Ignore errors on lastUsed update
+
+ req.user = apiKeyRecord.user;
+ return next();
+ }
+ } catch (error) {
+ console.error("API key auth error:", error);
+ }
+ }
+
+ // Fallback: check JWT token in Authorization header
+ const authHeader = req.headers.authorization;
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
+
+ if (token) {
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
+ const user = await prisma.user.findUnique({
+ where: { id: decoded.userId },
+ select: { id: true, username: true, role: true },
+ });
+
+ if (user) {
+ req.user = user;
+ return next();
+ }
+ } catch (error) {
+ // Token invalid, continue to error
+ }
+ }
+
+ return res.status(401).json({ error: "Not authenticated" });
+}
+
+export async function requireAdmin(req: Request, res: Response, next: NextFunction) {
+ if (!req.user || req.user.role !== "admin") {
+ return res.status(403).json({ error: "Admin access required" });
+ }
+ next();
+}
+
+// For streaming URLs that may use query params or need special handling
+export async function requireAuthOrToken(
+ req: Request,
+ res: Response,
+ next: NextFunction
+) {
+ // First, check session-based auth (primary method for web)
+ if (req.session?.userId) {
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: req.session.userId },
+ select: { id: true, username: true, role: true },
+ });
+
+ if (user) {
+ req.user = user;
+ return next();
+ }
+ } catch (error) {
+ console.error("Session auth error:", error);
+ }
+ }
+
+ // Check for API key in X-API-Key header (for mobile/external apps)
+ const apiKey = req.headers["x-api-key"] as string;
+ if (apiKey) {
+ try {
+ const apiKeyRecord = await prisma.apiKey.findUnique({
+ where: { key: apiKey },
+ include: { user: { select: { id: true, username: true, role: true } } },
+ });
+
+ if (apiKeyRecord && apiKeyRecord.user) {
+ // Update last used timestamp (async, don't block)
+ prisma.apiKey.update({
+ where: { id: apiKeyRecord.id },
+ data: { lastUsed: new Date() },
+ }).catch(() => {}); // Ignore errors on lastUsed update
+
+ req.user = apiKeyRecord.user;
+ return next();
+ }
+ } catch (error) {
+ console.error("API key auth error:", error);
+ }
+ }
+
+ // Check for token in query param (for streaming URLs from audio elements)
+ const tokenParam = req.query.token as string;
+ if (tokenParam) {
+ try {
+ const decoded = jwt.verify(tokenParam, JWT_SECRET) as JWTPayload;
+ const user = await prisma.user.findUnique({
+ where: { id: decoded.userId },
+ select: { id: true, username: true, role: true },
+ });
+
+ if (user) {
+ req.user = user;
+ return next();
+ }
+ } catch (error) {
+ // Token invalid, try other methods
+ }
+ }
+
+ // Fallback: check JWT token in Authorization header
+ const authHeader = req.headers.authorization;
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
+
+ if (token) {
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
+ const user = await prisma.user.findUnique({
+ where: { id: decoded.userId },
+ select: { id: true, username: true, role: true },
+ });
+
+ if (user) {
+ req.user = user;
+ return next();
+ }
+ } catch (error) {
+ // Token invalid, continue to error
+ }
+ }
+
+ return res.status(401).json({ error: "Not authenticated" });
+}
diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts
new file mode 100644
index 0000000..3dde8b1
--- /dev/null
+++ b/backend/src/middleware/errorHandler.ts
@@ -0,0 +1,11 @@
+import { Request, Response, NextFunction } from "express";
+
+export function errorHandler(
+ err: Error,
+ req: Request,
+ res: Response,
+ next: NextFunction
+) {
+ console.error(err.stack);
+ res.status(500).json({ error: "Internal server error" });
+}
diff --git a/backend/src/middleware/rateLimiter.ts b/backend/src/middleware/rateLimiter.ts
new file mode 100644
index 0000000..f14e47e
--- /dev/null
+++ b/backend/src/middleware/rateLimiter.ts
@@ -0,0 +1,58 @@
+import rateLimit from "express-rate-limit";
+
+// General API rate limiter (5000 req/minute per IP)
+// This is for a single-user self-hosted app, so limits should be VERY high
+// Only exists to prevent infinite loops or bugs from DOS'ing the server
+export const apiLimiter = rateLimit({
+ windowMs: 1 * 60 * 1000, // 1 minute
+ max: 5000, // Very high limit - personal app, not a public API
+ message: "Too many requests from this IP, please try again later.",
+ standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
+ legacyHeaders: false, // Disable the `X-RateLimit-*` headers
+ skip: (req) => {
+ // Never rate limit streaming or status polling endpoints
+ return req.path.includes("/stream") ||
+ req.path.includes("/status") ||
+ req.path.includes("/health");
+ },
+});
+
+// Auth limiter for login endpoints (20 attempts/15min per IP)
+// More lenient for self-hosted apps where users may have password manager issues
+export const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: 20, // Increased from 5 for self-hosted environments
+ skipSuccessfulRequests: true, // Don't count successful requests
+ message: "Too many login attempts, please try again in 15 minutes.",
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+// Media streaming limiter (higher limit: 200 streams/minute)
+export const streamLimiter = rateLimit({
+ windowMs: 1 * 60 * 1000, // 1 minute
+ max: 200, // Allow 200 stream requests per minute
+ message: "Too many streaming requests, please slow down.",
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+// Image/Cover art limiter (very high limit: 500 req/minute)
+// This is for image proxying - not a security risk, just bandwidth
+export const imageLimiter = rateLimit({
+ windowMs: 1 * 60 * 1000, // 1 minute
+ max: 500, // Allow 500 image requests per minute (high volume pages need this)
+ message: "Too many image requests, please slow down.",
+ standardHeaders: true,
+ legacyHeaders: false,
+});
+
+// Download limiter (100 req/minute)
+// Users might download entire discographies, so this needs to be reasonable
+export const downloadLimiter = rateLimit({
+ windowMs: 1 * 60 * 1000, // 1 minute
+ max: 100,
+ message: "Too many download requests, please try again later.",
+ standardHeaders: true,
+ legacyHeaders: false,
+});
diff --git a/backend/src/routes/analysis.ts b/backend/src/routes/analysis.ts
new file mode 100644
index 0000000..0cb9d89
--- /dev/null
+++ b/backend/src/routes/analysis.ts
@@ -0,0 +1,293 @@
+import { Router } from "express";
+import { prisma } from "../utils/db";
+import { redisClient } from "../utils/redis";
+import { requireAuth, requireAdmin } from "../middleware/auth";
+
+const router = Router();
+
+// Redis queue key for audio analysis
+const ANALYSIS_QUEUE = "audio:analysis:queue";
+
+/**
+ * GET /api/analysis/status
+ * Get audio analysis status and progress
+ */
+router.get("/status", requireAuth, async (req, res) => {
+ try {
+ // Get counts by status
+ const statusCounts = await prisma.track.groupBy({
+ by: ["analysisStatus"],
+ _count: true,
+ });
+
+ const total = statusCounts.reduce((sum, s) => sum + s._count, 0);
+ const completed = statusCounts.find(s => s.analysisStatus === "completed")?._count || 0;
+ const failed = statusCounts.find(s => s.analysisStatus === "failed")?._count || 0;
+ const processing = statusCounts.find(s => s.analysisStatus === "processing")?._count || 0;
+ const pending = statusCounts.find(s => s.analysisStatus === "pending")?._count || 0;
+
+ // Get queue length from Redis
+ const queueLength = await redisClient.lLen(ANALYSIS_QUEUE);
+
+ const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
+
+ res.json({
+ total,
+ completed,
+ failed,
+ processing,
+ pending,
+ queueLength,
+ progress,
+ isComplete: pending === 0 && processing === 0 && queueLength === 0,
+ });
+ } catch (error: any) {
+ console.error("Analysis status error:", error);
+ res.status(500).json({ error: "Failed to get analysis status" });
+ }
+});
+
+/**
+ * POST /api/analysis/start
+ * Start audio analysis for pending tracks (admin only)
+ */
+router.post("/start", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { limit = 100, priority = "recent" } = req.body;
+
+ // Find pending tracks
+ const tracks = await prisma.track.findMany({
+ where: {
+ analysisStatus: "pending",
+ },
+ select: {
+ id: true,
+ filePath: true,
+ },
+ orderBy: priority === "recent"
+ ? { fileModified: "desc" }
+ : { title: "asc" },
+ take: Math.min(limit, 1000),
+ });
+
+ if (tracks.length === 0) {
+ return res.json({
+ message: "No pending tracks to analyze",
+ queued: 0,
+ });
+ }
+
+ // Queue tracks for analysis
+ const pipeline = redisClient.multi();
+ for (const track of tracks) {
+ pipeline.rPush(ANALYSIS_QUEUE, JSON.stringify({
+ trackId: track.id,
+ filePath: track.filePath,
+ }));
+ }
+ await pipeline.exec();
+
+ console.log(`Queued ${tracks.length} tracks for audio analysis`);
+
+ res.json({
+ message: `Queued ${tracks.length} tracks for analysis`,
+ queued: tracks.length,
+ });
+ } catch (error: any) {
+ console.error("Analysis start error:", error);
+ res.status(500).json({ error: "Failed to start analysis" });
+ }
+});
+
+/**
+ * POST /api/analysis/retry-failed
+ * Retry failed analysis jobs (admin only)
+ */
+router.post("/retry-failed", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ // Reset failed tracks to pending
+ const result = await prisma.track.updateMany({
+ where: {
+ analysisStatus: "failed",
+ },
+ data: {
+ analysisStatus: "pending",
+ analysisError: null,
+ },
+ });
+
+ res.json({
+ message: `Reset ${result.count} failed tracks to pending`,
+ reset: result.count,
+ });
+ } catch (error: any) {
+ console.error("Retry failed error:", error);
+ res.status(500).json({ error: "Failed to retry analysis" });
+ }
+});
+
+/**
+ * POST /api/analysis/analyze/:trackId
+ * Queue a specific track for analysis
+ */
+router.post("/analyze/:trackId", requireAuth, async (req, res) => {
+ try {
+ const { trackId } = req.params;
+
+ const track = await prisma.track.findUnique({
+ where: { id: trackId },
+ select: {
+ id: true,
+ filePath: true,
+ analysisStatus: true,
+ },
+ });
+
+ if (!track) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Queue for analysis
+ await redisClient.rPush(ANALYSIS_QUEUE, JSON.stringify({
+ trackId: track.id,
+ filePath: track.filePath,
+ }));
+
+ // Mark as pending if not already
+ if (track.analysisStatus !== "processing") {
+ await prisma.track.update({
+ where: { id: trackId },
+ data: { analysisStatus: "pending" },
+ });
+ }
+
+ res.json({
+ message: "Track queued for analysis",
+ trackId,
+ });
+ } catch (error: any) {
+ console.error("Analyze track error:", error);
+ res.status(500).json({ error: "Failed to queue track for analysis" });
+ }
+});
+
+/**
+ * GET /api/analysis/track/:trackId
+ * Get analysis data for a specific track
+ */
+router.get("/track/:trackId", requireAuth, async (req, res) => {
+ try {
+ const { trackId } = req.params;
+
+ const track = await prisma.track.findUnique({
+ where: { id: trackId },
+ select: {
+ id: true,
+ title: true,
+ analysisStatus: true,
+ analysisError: true,
+ analyzedAt: true,
+ analysisVersion: true,
+ bpm: true,
+ beatsCount: true,
+ key: true,
+ keyScale: true,
+ keyStrength: true,
+ energy: true,
+ loudness: true,
+ dynamicRange: true,
+ danceability: true,
+ valence: true,
+ arousal: true,
+ instrumentalness: true,
+ acousticness: true,
+ speechiness: true,
+ moodTags: true,
+ essentiaGenres: true,
+ lastfmTags: true,
+ },
+ });
+
+ if (!track) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ res.json(track);
+ } catch (error: any) {
+ console.error("Get track analysis error:", error);
+ res.status(500).json({ error: "Failed to get track analysis" });
+ }
+});
+
+/**
+ * GET /api/analysis/features
+ * Get aggregated feature statistics for the library
+ */
+router.get("/features", requireAuth, async (req, res) => {
+ try {
+ // Get analyzed tracks
+ const analyzed = await prisma.track.findMany({
+ where: {
+ analysisStatus: "completed",
+ bpm: { not: null },
+ },
+ select: {
+ bpm: true,
+ energy: true,
+ danceability: true,
+ valence: true,
+ keyScale: true,
+ },
+ });
+
+ if (analyzed.length === 0) {
+ return res.json({
+ count: 0,
+ averages: null,
+ distributions: null,
+ });
+ }
+
+ // Calculate averages
+ const avgBpm = analyzed.reduce((sum, t) => sum + (t.bpm || 0), 0) / analyzed.length;
+ const avgEnergy = analyzed.reduce((sum, t) => sum + (t.energy || 0), 0) / analyzed.length;
+ const avgDanceability = analyzed.reduce((sum, t) => sum + (t.danceability || 0), 0) / analyzed.length;
+ const avgValence = analyzed.reduce((sum, t) => sum + (t.valence || 0), 0) / analyzed.length;
+
+ // Key distribution
+ const majorCount = analyzed.filter(t => t.keyScale === "major").length;
+ const minorCount = analyzed.filter(t => t.keyScale === "minor").length;
+
+ // BPM distribution (buckets)
+ const bpmBuckets = {
+ slow: analyzed.filter(t => (t.bpm || 0) < 90).length,
+ moderate: analyzed.filter(t => (t.bpm || 0) >= 90 && (t.bpm || 0) < 120).length,
+ upbeat: analyzed.filter(t => (t.bpm || 0) >= 120 && (t.bpm || 0) < 150).length,
+ fast: analyzed.filter(t => (t.bpm || 0) >= 150).length,
+ };
+
+ res.json({
+ count: analyzed.length,
+ averages: {
+ bpm: Math.round(avgBpm),
+ energy: Math.round(avgEnergy * 100) / 100,
+ danceability: Math.round(avgDanceability * 100) / 100,
+ valence: Math.round(avgValence * 100) / 100,
+ },
+ distributions: {
+ key: { major: majorCount, minor: minorCount },
+ bpm: bpmBuckets,
+ },
+ });
+ } catch (error: any) {
+ console.error("Get features error:", error);
+ res.status(500).json({ error: "Failed to get feature statistics" });
+ }
+});
+
+export default router;
+
+
+
+
+
diff --git a/backend/src/routes/apiKeys.ts b/backend/src/routes/apiKeys.ts
new file mode 100644
index 0000000..52fdf34
--- /dev/null
+++ b/backend/src/routes/apiKeys.ts
@@ -0,0 +1,231 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import crypto from "crypto";
+
+const router = Router();
+
+// All API key routes require authentication (session-based)
+router.use(requireAuth);
+
+/**
+ * @openapi
+ * /api-keys:
+ * post:
+ * summary: Create a new API key for mobile/external authentication
+ * tags: [API Keys]
+ * security:
+ * - sessionAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - deviceName
+ * properties:
+ * deviceName:
+ * type: string
+ * description: Name of the device (e.g., "iPhone 14", "Android Tablet")
+ * example: "iPhone 14"
+ * responses:
+ * 201:
+ * description: API key created successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * apiKey:
+ * type: string
+ * description: The generated API key (64-character hex string)
+ * example: "a1b2c3d4e5f6..."
+ * name:
+ * type: string
+ * example: "iPhone 14"
+ * createdAt:
+ * type: string
+ * format: date-time
+ * message:
+ * type: string
+ * example: "API key created successfully. Save this key - you won't see it again!"
+ * 400:
+ * description: Invalid request
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.post("/", async (req, res) => {
+ try {
+ const { deviceName } = req.body;
+
+ if (!deviceName || deviceName.trim().length === 0) {
+ return res.status(400).json({ error: "Device name is required" });
+ }
+
+ // Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
+ const userId = req.user?.id || req.session?.userId;
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ // Generate a secure random API key (32 bytes = 64 hex chars)
+ const apiKeyValue = crypto.randomBytes(32).toString("hex");
+
+ const apiKey = await prisma.apiKey.create({
+ data: {
+ userId,
+ name: deviceName.trim(),
+ key: apiKeyValue,
+ },
+ });
+
+ console.log(`API key created for user ${userId}: ${deviceName}`);
+
+ res.status(201).json({
+ apiKey: apiKey.key,
+ name: apiKey.name,
+ createdAt: apiKey.createdAt,
+ message:
+ "API key created successfully. Save this key - you won't see it again!",
+ });
+ } catch (error) {
+ console.error("Create API key error:", error);
+ res.status(500).json({ error: "Failed to create API key" });
+ }
+});
+
+/**
+ * @openapi
+ * /api-keys:
+ * get:
+ * summary: List all API keys for the current user
+ * tags: [API Keys]
+ * security:
+ * - sessionAuth: []
+ * responses:
+ * 200:
+ * description: List of API keys (without the actual key values for security)
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * apiKeys:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/ApiKey'
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get("/", async (req, res) => {
+ try {
+ // Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
+ const userId = req.user?.id || req.session?.userId;
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ const keys = await prisma.apiKey.findMany({
+ where: { userId },
+ select: {
+ id: true,
+ name: true,
+ lastUsed: true,
+ createdAt: true,
+ // Don't return the actual key for security!
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ res.json({ apiKeys: keys });
+ } catch (error) {
+ console.error("List API keys error:", error);
+ res.status(500).json({ error: "Failed to list API keys" });
+ }
+});
+
+/**
+ * @openapi
+ * /api-keys/{id}:
+ * delete:
+ * summary: Revoke an API key
+ * tags: [API Keys]
+ * security:
+ * - sessionAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: The API key ID
+ * responses:
+ * 200:
+ * description: API key revoked successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * message:
+ * type: string
+ * example: "API key revoked successfully"
+ * 404:
+ * description: API key not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.delete("/:id", async (req, res) => {
+ try {
+ // Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
+ const userId = req.user?.id || req.session?.userId;
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+ const keyId = req.params.id;
+
+ // Only allow users to delete their own keys
+ const deleted = await prisma.apiKey.deleteMany({
+ where: {
+ id: keyId,
+ userId,
+ },
+ });
+
+ if (deleted.count === 0) {
+ return res
+ .status(404)
+ .json({ error: "API key not found or already deleted" });
+ }
+
+ console.log(`API key ${keyId} revoked by user ${userId}`);
+
+ res.json({ message: "API key revoked successfully" });
+ } catch (error) {
+ console.error("Delete API key error:", error);
+ res.status(500).json({ error: "Failed to revoke API key" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/artists.ts b/backend/src/routes/artists.ts
new file mode 100644
index 0000000..651da6a
--- /dev/null
+++ b/backend/src/routes/artists.ts
@@ -0,0 +1,566 @@
+import { Router } from "express";
+import { lastFmService } from "../services/lastfm";
+import { musicBrainzService } from "../services/musicbrainz";
+import { fanartService } from "../services/fanart";
+import { deezerService } from "../services/deezer";
+import { redisClient } from "../utils/redis";
+
+const router = Router();
+
+// Cache TTL for discovery content (shorter since it's not owned)
+const DISCOVERY_CACHE_TTL = 24 * 60 * 60; // 24 hours
+
+// GET /artists/preview/:artistName/:trackTitle - Get Deezer preview URL for a track
+router.get("/preview/:artistName/:trackTitle", async (req, res) => {
+ try {
+ const { artistName, trackTitle } = req.params;
+ const decodedArtist = decodeURIComponent(artistName);
+ const decodedTrack = decodeURIComponent(trackTitle);
+
+ console.log(
+ `Getting preview for "${decodedTrack}" by ${decodedArtist}`
+ );
+
+ const previewUrl = await deezerService.getTrackPreview(
+ decodedArtist,
+ decodedTrack
+ );
+
+ if (previewUrl) {
+ res.json({ previewUrl });
+ } else {
+ res.status(404).json({ error: "Preview not found" });
+ }
+ } catch (error: any) {
+ console.error("Preview fetch error:", error);
+ res.status(500).json({
+ error: "Failed to fetch preview",
+ message: error.message,
+ });
+ }
+});
+
+// GET /artists/discover/:nameOrMbid - Get artist details for discovery (not in library yet)
+router.get("/discover/:nameOrMbid", async (req, res) => {
+ try {
+ const { nameOrMbid } = req.params;
+
+ // Check Redis cache first for discovery content
+ const cacheKey = `discovery:artist:${nameOrMbid}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(`[Discovery] Cache hit for artist: ${nameOrMbid}`);
+ return res.json(JSON.parse(cached));
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ // Check if it's an MBID (UUID format) or name
+ const isMbid =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ nameOrMbid
+ );
+
+ let mbid: string | null = isMbid ? nameOrMbid : null;
+ let artistName: string = isMbid ? "" : decodeURIComponent(nameOrMbid);
+
+ // If we have a name but no MBID, search for it
+ if (!mbid && artistName) {
+ const mbResults = await musicBrainzService.searchArtist(
+ artistName,
+ 1
+ );
+ if (mbResults.length > 0) {
+ mbid = mbResults[0].id;
+ artistName = mbResults[0].name;
+ }
+ }
+
+ // If we have MBID but no name, get it from MusicBrainz
+ if (mbid && !artistName) {
+ const mbArtist = await musicBrainzService.getArtist(mbid);
+ artistName = mbArtist.name;
+ }
+
+ if (!artistName) {
+ return res.status(404).json({ error: "Artist not found" });
+ }
+
+ // Get artist info from Last.fm
+ const lastFmInfo = await lastFmService.getArtistInfo(
+ artistName,
+ mbid || undefined
+ );
+
+ // Filter out generic "multiple artists" biographies from Last.fm
+ // These occur when Last.fm groups artists with the same name
+ let bio = lastFmInfo?.bio?.summary || null;
+ if (bio) {
+ const lowerBio = bio.toLowerCase();
+ if (
+ (lowerBio.includes("there are") &&
+ (lowerBio.includes("artist") ||
+ lowerBio.includes("band")) &&
+ lowerBio.includes("with the name")) ||
+ lowerBio.includes("there is more than one artist") ||
+ lowerBio.includes("multiple artists")
+ ) {
+ // This is a disambiguation page - don't show it
+ console.log(
+ ` Filtered out disambiguation biography for ${artistName}`
+ );
+ bio = null;
+ }
+ }
+
+ // Get top tracks from Last.fm
+ let topTracks: any[] = [];
+ if (mbid || artistName) {
+ try {
+ topTracks = await lastFmService.getArtistTopTracks(
+ mbid || "",
+ artistName,
+ 10
+ );
+ } catch (error) {
+ console.log(`Failed to get top tracks for ${artistName}`);
+ }
+ }
+
+ // Get artist image
+ let image = null;
+
+ // Try Fanart.tv first (if we have MBID)
+ if (mbid) {
+ try {
+ image = await fanartService.getArtistImage(mbid);
+ console.log(`Fanart.tv image for ${artistName}`);
+ } catch (error) {
+ console.log(
+ `✗ Failed to get Fanart.tv image for ${artistName}`
+ );
+ }
+ }
+
+ // Fallback to Deezer
+ if (!image) {
+ try {
+ image = await deezerService.getArtistImage(artistName);
+ if (image) {
+ console.log(`Deezer image for ${artistName}`);
+ }
+ } catch (error) {
+ console.log(`✗ Failed to get Deezer image for ${artistName}`);
+ }
+ }
+
+ // Fallback to Last.fm (but filter placeholders)
+ if (!image && lastFmInfo?.image) {
+ const lastFmImage = lastFmService.getBestImage(lastFmInfo.image);
+ // Filter out Last.fm placeholder
+ if (
+ lastFmImage &&
+ !lastFmImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
+ ) {
+ image = lastFmImage;
+ console.log(`Last.fm image for ${artistName}`);
+ } else {
+ console.log(`✗ Last.fm returned placeholder for ${artistName}`);
+ }
+ }
+
+ // Get discography from MusicBrainz
+ let albums: any[] = [];
+ if (mbid) {
+ try {
+ const releaseGroups = await musicBrainzService.getReleaseGroups(
+ mbid
+ );
+
+ // Filter albums - only show studio albums and EPs
+ // Exclude live albums, compilations, soundtracks, remixes, etc.
+ const filteredReleaseGroups = releaseGroups.filter(
+ (rg: any) => {
+ // Must be Album or EP
+ const isPrimaryType =
+ rg["primary-type"] === "Album" ||
+ rg["primary-type"] === "EP";
+ if (!isPrimaryType) return false;
+
+ // Exclude secondary types (live, compilation, soundtrack, remix, etc.)
+ const secondaryTypes = rg["secondary-types"] || [];
+ const hasExcludedType = secondaryTypes.some(
+ (type: string) =>
+ [
+ "Live",
+ "Compilation",
+ "Soundtrack",
+ "Remix",
+ "DJ-mix",
+ "Mixtape/Street",
+ ].includes(type)
+ );
+
+ return !hasExcludedType;
+ }
+ );
+
+ // Process albums with Deezer fallback
+ albums = await Promise.all(
+ filteredReleaseGroups.map(async (rg: any) => {
+ // Default to Cover Art Archive URL
+ let coverUrl = `https://coverartarchive.org/release-group/${rg.id}/front-500`;
+
+ // For first 10 albums, try Deezer as fallback if Cover Art Archive doesn't have it
+ // (to avoid too many requests)
+ const index = filteredReleaseGroups.indexOf(rg);
+ if (index < 10) {
+ try {
+ const response = await fetch(coverUrl, {
+ method: "HEAD",
+ signal: AbortSignal.timeout(2000),
+ });
+ if (!response.ok) {
+ // Cover Art Archive doesn't have it, try Deezer
+ const deezerCover =
+ await deezerService.getAlbumCover(
+ artistName,
+ rg.title
+ );
+ if (deezerCover) {
+ coverUrl = deezerCover;
+ }
+ }
+ } catch (error) {
+ // Silently fail and keep Cover Art Archive URL
+ }
+ }
+
+ return {
+ id: rg.id, // MBID - used for linking
+ rgMbid: rg.id, // Release group MBID - used for downloads
+ mbid: rg.id, // Fallback MBID
+ title: rg.title,
+ type: rg["primary-type"],
+ year: rg["first-release-date"]
+ ? parseInt(
+ rg["first-release-date"].substring(0, 4)
+ )
+ : null,
+ releaseDate: rg["first-release-date"] || null,
+ coverUrl,
+ owned: false, // Discovery albums are never owned
+ };
+ })
+ );
+
+ // Sort albums
+ albums.sort((a: any, b: any) => {
+ // Sort by year descending (newest first)
+ if (a.year && b.year) return b.year - a.year;
+ if (a.year) return -1;
+ if (b.year) return 1;
+ return 0;
+ });
+ } catch (error) {
+ console.error(
+ `Failed to get discography for ${artistName}:`,
+ error
+ );
+ }
+ }
+
+ // Get similar artists from Last.fm and fetch images
+ const similarArtistsRaw = lastFmInfo?.similar?.artist || [];
+ const similarArtists = await Promise.all(
+ similarArtistsRaw.slice(0, 10).map(async (artist: any) => {
+ const similarImage = artist.image?.find(
+ (img: any) => img.size === "large"
+ )?.[" #text"];
+
+ let image = null;
+
+ // Try Fanart.tv first (if we have MBID)
+ if (artist.mbid) {
+ try {
+ image = await fanartService.getArtistImage(artist.mbid);
+ } catch (error) {
+ // Silently fail
+ }
+ }
+
+ // Fallback to Deezer
+ if (!image) {
+ try {
+ const deezerImage = await deezerService.getArtistImage(
+ artist.name
+ );
+ if (deezerImage) {
+ image = deezerImage;
+ }
+ } catch (error) {
+ // Silently fail
+ }
+ }
+
+ // Last fallback to Last.fm (but filter placeholders)
+ if (
+ !image &&
+ similarImage &&
+ !similarImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
+ ) {
+ image = similarImage;
+ }
+
+ return {
+ id: artist.mbid || artist.name,
+ name: artist.name,
+ mbid: artist.mbid || null,
+ url: artist.url,
+ image,
+ };
+ })
+ );
+
+ const response = {
+ mbid,
+ name: artistName,
+ image,
+ bio, // Use filtered bio instead of raw Last.fm bio
+ summary: bio, // Alias for consistency
+ tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
+ genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency
+ listeners: parseInt(lastFmInfo?.stats?.listeners || "0"),
+ playcount: parseInt(lastFmInfo?.stats?.playcount || "0"),
+ url: lastFmInfo?.url || null,
+ albums: albums.map((album) => ({ ...album, owned: false })), // Mark all as not owned
+ topTracks: topTracks.map((track) => ({
+ id: `lastfm-${mbid || artistName}-${track.name}`,
+ title: track.name,
+ playCount: parseInt(track.playcount || "0"),
+ listeners: parseInt(track.listeners || "0"),
+ duration: parseInt(track.duration || "0"),
+ url: track.url,
+ album: { title: track.album?.["#text"] || "Unknown Album" },
+ })),
+ similarArtists,
+ };
+
+ // Cache discovery response for 24 hours
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ DISCOVERY_CACHE_TTL,
+ JSON.stringify(response)
+ );
+ console.log(`[Discovery] Cached artist: ${artistName}`);
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ res.json(response);
+ } catch (error: any) {
+ console.error("Artist discovery error:", error);
+ res.status(500).json({
+ error: "Failed to fetch artist details",
+ message: error.message,
+ });
+ }
+});
+
+// GET /artists/album/:mbid - Get album details for discovery (not in library yet)
+router.get("/album/:mbid", async (req, res) => {
+ try {
+ const { mbid } = req.params;
+
+ // Check Redis cache first for discovery content
+ const cacheKey = `discovery:album:${mbid}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(`[Discovery] Cache hit for album: ${mbid}`);
+ return res.json(JSON.parse(cached));
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ let releaseGroup: any = null;
+ let release: any = null;
+ let releaseGroupId: string = mbid;
+
+ // Try as release-group first, then as release
+ try {
+ releaseGroup = await musicBrainzService.getReleaseGroup(mbid);
+ } catch (error: any) {
+ // If 404, try as a release instead
+ if (error.response?.status === 404) {
+ console.log(
+ `${mbid} is not a release-group, trying as release...`
+ );
+ release = await musicBrainzService.getRelease(mbid);
+ releaseGroupId = release["release-group"]?.id || mbid;
+
+ // Now get the release group to get the type and first-release-date
+ if (releaseGroupId) {
+ try {
+ releaseGroup = await musicBrainzService.getReleaseGroup(
+ releaseGroupId
+ );
+ } catch (err) {
+ console.error(
+ `Failed to get release-group ${releaseGroupId}`
+ );
+ }
+ }
+ } else {
+ throw error;
+ }
+ }
+
+ if (!releaseGroup && !release) {
+ return res.status(404).json({ error: "Album not found" });
+ }
+
+ // Get the artist name and MBID from either release-group or release
+ const artistCredit =
+ releaseGroup?.["artist-credit"] || release?.["artist-credit"];
+ const artistName = artistCredit?.[0]?.name || "Unknown Artist";
+ const artistMbid = artistCredit?.[0]?.artist?.id;
+ const albumTitle = releaseGroup?.title || release?.title;
+
+ // Get album info from Last.fm
+ let lastFmInfo = null;
+ try {
+ lastFmInfo = await lastFmService.getAlbumInfo(
+ artistName,
+ albumTitle
+ );
+ } catch (error) {
+ console.log(`Failed to get Last.fm info for ${albumTitle}`);
+ }
+
+ // Get tracks - if we have release, use it directly; otherwise get first release from group
+ let tracks: any[] = [];
+ if (release) {
+ tracks = release.media?.[0]?.tracks || [];
+ } else if (releaseGroup?.releases && releaseGroup.releases.length > 0) {
+ const firstRelease = releaseGroup.releases[0];
+ try {
+ const releaseDetails = await musicBrainzService.getRelease(
+ firstRelease.id
+ );
+ tracks = releaseDetails.media?.[0]?.tracks || [];
+ } catch (error) {
+ console.error(
+ `Failed to get tracks for release ${firstRelease.id}`
+ );
+ }
+ }
+
+ // Get album cover art - try Cover Art Archive first
+ let coverUrl = null;
+ let coverArtUrl = `https://coverartarchive.org/release/${mbid}/front-500`;
+ if (!release) {
+ coverArtUrl = `https://coverartarchive.org/release-group/${releaseGroupId}/front-500`;
+ }
+
+ // Check if Cover Art Archive actually has the image
+ try {
+ const response = await fetch(coverArtUrl, { method: "HEAD" });
+ if (response.ok) {
+ coverUrl = coverArtUrl;
+ console.log(`Cover Art Archive has cover for ${albumTitle}`);
+ } else {
+ console.log(
+ `✗ Cover Art Archive 404 for ${albumTitle}, trying Deezer...`
+ );
+ }
+ } catch (error) {
+ console.log(
+ `✗ Cover Art Archive check failed for ${albumTitle}, trying Deezer...`
+ );
+ }
+
+ // Fallback to Deezer if Cover Art Archive doesn't have it
+ if (!coverUrl) {
+ try {
+ const deezerCover = await deezerService.getAlbumCover(
+ artistName,
+ albumTitle
+ );
+ if (deezerCover) {
+ coverUrl = deezerCover;
+ console.log(`Deezer has cover for ${albumTitle}`);
+ } else {
+ // Final fallback to Cover Art Archive URL (might 404, but better than nothing)
+ coverUrl = coverArtUrl;
+ }
+ } catch (error) {
+ console.log(`✗ Deezer lookup failed for ${albumTitle}`);
+ // Final fallback to Cover Art Archive URL
+ coverUrl = coverArtUrl;
+ }
+ }
+
+ // Format response
+ const releaseMbid = release?.id || null;
+
+ const response = {
+ id: releaseGroupId,
+ rgMbid: releaseGroupId,
+ mbid: releaseMbid || releaseGroupId,
+ releaseMbid,
+ title: albumTitle,
+ artist: {
+ name: artistName,
+ id: artistMbid || artistName,
+ mbid: artistMbid,
+ },
+ year: releaseGroup?.["first-release-date"]
+ ? parseInt(releaseGroup["first-release-date"].substring(0, 4))
+ : release?.date
+ ? parseInt(release.date.substring(0, 4))
+ : null,
+ type: releaseGroup?.["primary-type"] || "Album",
+ coverUrl,
+ coverArt: coverUrl, // Alias for compatibility
+ bio: lastFmInfo?.wiki?.summary || null,
+ tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
+ tracks: tracks.map((track: any, index: number) => ({
+ id: `mb-${releaseGroupId}-${track.id || index}`,
+ title: track.title,
+ trackNo: track.position || index + 1,
+ duration: track.length ? Math.floor(track.length / 1000) : 0,
+ artist: { name: artistName },
+ })),
+ similarAlbums: [], // Similar album recommendations not yet implemented
+ owned: false,
+ source: "discovery",
+ };
+
+ // Cache discovery response for 24 hours
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ DISCOVERY_CACHE_TTL,
+ JSON.stringify(response)
+ );
+ console.log(`[Discovery] Cached album: ${albumTitle}`);
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ res.json(response);
+ } catch (error: any) {
+ console.error("Album discovery error:", error);
+ res.status(500).json({
+ error: "Failed to fetch album details",
+ message: error.message,
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/audiobooks.ts b/backend/src/routes/audiobooks.ts
new file mode 100644
index 0000000..b44f1c9
--- /dev/null
+++ b/backend/src/routes/audiobooks.ts
@@ -0,0 +1,907 @@
+import { Router } from "express";
+import { audiobookshelfService } from "../services/audiobookshelf";
+import { audiobookCacheService } from "../services/audiobookCache";
+import { prisma } from "../utils/db";
+import { requireAuthOrToken } from "../middleware/auth";
+import { imageLimiter, apiLimiter } from "../middleware/rateLimiter";
+
+const router = Router();
+
+/**
+ * GET /audiobooks/continue-listening
+ * Get audiobooks the user is currently listening to (for "Continue Listening" section)
+ * NOTE: This must come BEFORE the /:id route to avoid matching "continue-listening" as an ID
+ */
+router.get(
+ "/continue-listening",
+ requireAuthOrToken,
+ apiLimiter,
+ async (req, res) => {
+ try {
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json([]);
+ }
+
+ const recentProgress = await prisma.audiobookProgress.findMany({
+ where: {
+ userId: req.user!.id,
+ isFinished: false,
+ currentTime: {
+ gt: 0,
+ },
+ },
+ orderBy: {
+ lastPlayedAt: "desc",
+ },
+ take: 10,
+ });
+
+ // Transform the cover URLs to use the audiobook__ prefix for the proxy
+ const transformed = recentProgress.map((progress: any) => {
+ const coverUrl =
+ progress.coverUrl && !progress.coverUrl.startsWith("http")
+ ? `audiobook__${progress.coverUrl}`
+ : progress.coverUrl;
+
+ return {
+ ...progress,
+ coverUrl,
+ };
+ });
+
+ res.json(transformed);
+ } catch (error: any) {
+ console.error("Error fetching continue listening:", error);
+ res.status(500).json({
+ error: "Failed to fetch continue listening",
+ message: error.message,
+ });
+ }
+ }
+);
+
+/**
+ * POST /audiobooks/sync
+ * Manually trigger audiobook sync from Audiobookshelf
+ * Fetches all audiobooks and caches metadata + cover images locally
+ */
+router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
+ try {
+ const { getSystemSettings } = await import("../utils/systemSettings");
+ const { notificationService } = await import("../services/notificationService");
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res
+ .status(400)
+ .json({ error: "Audiobookshelf not enabled" });
+ }
+
+ console.log("[Audiobooks] Starting manual audiobook sync...");
+ const result = await audiobookCacheService.syncAll();
+
+ // Check how many have series after sync
+ const seriesCount = await prisma.audiobook.count({
+ where: { series: { not: null } },
+ });
+ console.log(
+ `[Audiobooks] Sync complete. Books with series: ${seriesCount}`
+ );
+
+ // Send notification to user
+ if (req.user?.id) {
+ await notificationService.notifySystem(
+ req.user.id,
+ "Audiobook Sync Complete",
+ `Synced ${result.synced || 0} audiobooks (${seriesCount} with series)`
+ );
+ }
+
+ res.json({
+ success: true,
+ result,
+ });
+ } catch (error: any) {
+ console.error("Audiobook sync failed:", error);
+ res.status(500).json({
+ error: "Sync failed",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /audiobooks/debug-series
+ * Debug endpoint to see raw series data from Audiobookshelf
+ */
+// Debug endpoint for series data
+router.get("/debug-series", requireAuthOrToken, async (req, res) => {
+ console.log("[Audiobooks] Debug series endpoint called");
+ try {
+ const { getSystemSettings } = await import("../utils/systemSettings");
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res
+ .status(400)
+ .json({ error: "Audiobookshelf not enabled" });
+ }
+
+ // Get raw data from Audiobookshelf
+ const rawBooks = await audiobookshelfService.getAllAudiobooks();
+ console.log(
+ `[Audiobooks] Got ${rawBooks.length} books from Audiobookshelf`
+ );
+
+ // Find books with series data
+ const booksWithSeries = rawBooks.filter((book: any) => {
+ const metadata = book.media?.metadata || book;
+ return metadata.series || metadata.seriesName;
+ });
+
+ console.log(
+ `[Audiobooks] Books with series data: ${booksWithSeries.length}`
+ );
+
+ // Extract series info from all books (first 20)
+ const allSeriesInfo = rawBooks.slice(0, 20).map((book: any) => {
+ const metadata = book.media?.metadata || book;
+ return {
+ title: metadata.title || book.title,
+ rawSeries: metadata.series,
+ seriesName: metadata.seriesName,
+ seriesSequence: metadata.seriesSequence,
+ // Also check if there's series in the top-level book object
+ bookSeries: book.series,
+ };
+ });
+
+ // Get a full sample of one book with series (if any)
+ let fullSample = null;
+ if (booksWithSeries.length > 0) {
+ const sampleBook = booksWithSeries[0];
+ fullSample = {
+ id: sampleBook.id,
+ media: sampleBook.media,
+ };
+ }
+
+ res.json({
+ totalBooks: rawBooks.length,
+ booksWithSeriesCount: booksWithSeries.length,
+ sampleSeriesData: allSeriesInfo,
+ fullSampleWithSeries: fullSample,
+ });
+ } catch (error: any) {
+ console.error("[Audiobooks] Debug series error:", error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
+/**
+ * GET /audiobooks/search
+ * Search audiobooks
+ */
+router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
+ try {
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import("../utils/systemSettings");
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json([]);
+ }
+
+ const { q } = req.query;
+
+ if (!q || typeof q !== "string") {
+ return res.status(400).json({ error: "Query parameter required" });
+ }
+
+ const results = await audiobookshelfService.searchAudiobooks(q);
+ res.json(results);
+ } catch (error: any) {
+ console.error("Error searching audiobooks:", error);
+ res.status(500).json({
+ error: "Failed to search audiobooks",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /audiobooks
+ * Get all audiobooks from cached database (instant, no API calls)
+ */
+router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
+ console.log("[Audiobooks] GET / - fetching audiobooks list");
+ try {
+ // Check if Audiobookshelf is enabled first
+ const { getSystemSettings } = await import("../utils/systemSettings");
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json({
+ configured: false,
+ enabled: false,
+ audiobooks: [],
+ });
+ }
+
+ // Read from cached database instead of hitting Audiobookshelf API
+ const audiobooks = await prisma.audiobook.findMany({
+ orderBy: { title: "asc" },
+ });
+
+ const audiobookIds = audiobooks.map((book) => book.id);
+ const progressEntries =
+ audiobookIds.length > 0
+ ? await prisma.audiobookProgress.findMany({
+ where: {
+ userId: req.user!.id,
+ audiobookshelfId: { in: audiobookIds },
+ },
+ })
+ : [];
+ const progressMap = new Map(
+ progressEntries.map((entry) => [entry.audiobookshelfId, entry])
+ );
+
+ // Get user's progress for each audiobook
+ const audiobooksWithProgress = audiobooks.map((book) => {
+ const progress = progressMap.get(book.id);
+
+ // Cover URL: if we have localCoverPath or coverUrl from Audiobookshelf, serve from our endpoint
+ // The /audiobooks/:id/cover endpoint will find the file on disk even if localCoverPath isn't set
+ const hasCover = book.localCoverPath || book.coverUrl;
+
+ return {
+ id: book.id,
+ title: book.title,
+ author: book.author || "Unknown Author",
+ narrator: book.narrator,
+ description: book.description,
+ coverUrl: hasCover
+ ? `/audiobooks/${book.id}/cover` // Serve from local disk
+ : null,
+ duration: book.duration || 0,
+ libraryId: book.libraryId,
+ series: book.series
+ ? {
+ name: book.series,
+ sequence: book.seriesSequence || "1",
+ }
+ : null,
+ genres: book.genres || [],
+ progress: progress
+ ? {
+ currentTime: progress.currentTime,
+ progress:
+ progress.duration > 0
+ ? (progress.currentTime / progress.duration) *
+ 100
+ : 0,
+ isFinished: progress.isFinished,
+ lastPlayedAt: progress.lastPlayedAt,
+ }
+ : null,
+ };
+ });
+
+ res.json(audiobooksWithProgress);
+ } catch (error: any) {
+ console.error("Error fetching audiobooks:", error);
+ res.status(500).json({
+ error: "Failed to fetch audiobooks",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /audiobooks/series/:seriesName
+ * Get all books in a series (from cached database)
+ */
+router.get(
+ "/series/:seriesName",
+ requireAuthOrToken,
+ apiLimiter,
+ async (req, res) => {
+ try {
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json([]);
+ }
+
+ const { seriesName } = req.params;
+ const decodedSeriesName = decodeURIComponent(seriesName);
+
+ // Read from cached database
+ const audiobooks = await prisma.audiobook.findMany({
+ where: {
+ series: decodedSeriesName,
+ },
+ orderBy: {
+ seriesSequence: "asc",
+ },
+ });
+
+ const seriesIds = audiobooks.map((book) => book.id);
+ const seriesProgressEntries =
+ seriesIds.length > 0
+ ? await prisma.audiobookProgress.findMany({
+ where: {
+ userId: req.user!.id,
+ audiobookshelfId: { in: seriesIds },
+ },
+ })
+ : [];
+ const seriesProgressMap = new Map(
+ seriesProgressEntries.map((entry) => [
+ entry.audiobookshelfId,
+ entry,
+ ])
+ );
+
+ const seriesBooks = audiobooks.map((book) => {
+ const progress = seriesProgressMap.get(book.id);
+
+ return {
+ id: book.id,
+ title: book.title,
+ author: book.author || "Unknown Author",
+ narrator: book.narrator,
+ description: book.description,
+ coverUrl:
+ book.localCoverPath || book.coverUrl
+ ? `/audiobooks/${book.id}/cover`
+ : null,
+ duration: book.duration || 0,
+ libraryId: book.libraryId,
+ series: book.series
+ ? {
+ name: book.series,
+ sequence: book.seriesSequence || "1",
+ }
+ : null,
+ genres: book.genres || [],
+ progress: progress
+ ? {
+ currentTime: progress.currentTime,
+ progress:
+ progress.duration > 0
+ ? (progress.currentTime /
+ progress.duration) *
+ 100
+ : 0,
+ isFinished: progress.isFinished,
+ lastPlayedAt: progress.lastPlayedAt,
+ }
+ : null,
+ };
+ });
+
+ res.json(seriesBooks);
+ } catch (error: any) {
+ console.error("Error fetching series:", error);
+ res.status(500).json({
+ error: "Failed to fetch series",
+ message: error.message,
+ });
+ }
+ }
+);
+
+/**
+ * OPTIONS /audiobooks/:id/cover
+ * Handle CORS preflight request for cover images
+ */
+router.options("/:id/cover", (req, res) => {
+ const origin = req.headers.origin || "http://localhost:3030";
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+ res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours
+ res.status(204).end();
+});
+
+/**
+ * GET /audiobooks/:id/cover
+ * Serve cached cover image from local disk (instant, no proxying)
+ * NO RATE LIMITING - These are static files served from disk with aggressive caching
+ */
+router.get("/:id/cover", async (req, res) => {
+ try {
+ const { id } = req.params;
+ const fs = await import("fs");
+ const path = await import("path");
+ const { config } = await import("../config");
+
+ const audiobook = await prisma.audiobook.findUnique({
+ where: { id },
+ select: { localCoverPath: true },
+ });
+
+ let coverPath = audiobook?.localCoverPath;
+
+ // Fallback: check if cover exists on disk even if DB path is empty
+ if (!coverPath) {
+ const fallbackPath = path.join(
+ config.music.musicPath,
+ "cover-cache",
+ "audiobooks",
+ `${id}.jpg`
+ );
+ if (fs.existsSync(fallbackPath)) {
+ coverPath = fallbackPath;
+ // Update database with the correct path
+ await prisma.audiobook
+ .update({
+ where: { id },
+ data: { localCoverPath: fallbackPath },
+ })
+ .catch(() => {}); // Ignore errors if audiobook doesn't exist
+ }
+ }
+
+ if (!coverPath) {
+ return res.status(404).json({ error: "Cover not found" });
+ }
+
+ // Verify file exists before sending
+ if (!fs.existsSync(coverPath)) {
+ return res.status(404).json({ error: "Cover file missing" });
+ }
+
+ // Serve image from local disk with aggressive caching and CORS headers
+ // Use specific origin instead of * to support credentials mode
+ const origin = req.headers.origin || "http://localhost:3030";
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
+ res.sendFile(coverPath);
+ } catch (error: any) {
+ console.error("Error serving cover:", error);
+ res.status(500).json({
+ error: "Failed to serve cover",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /audiobooks/:id
+ * Get a specific audiobook with full details (from cache, fallback to API)
+ */
+router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
+ try {
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import("../utils/systemSettings");
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json({ configured: false, enabled: false });
+ }
+
+ const { id } = req.params;
+
+ // Try to get from cache first
+ let audiobook = await prisma.audiobook.findUnique({
+ where: { id },
+ });
+
+ // If not cached or stale, fetch from API and cache it
+ if (
+ !audiobook ||
+ audiobook.lastSyncedAt <
+ new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
+ ) {
+ console.log(
+ `[AUDIOBOOK] Audiobook ${id} not cached or stale, fetching...`
+ );
+ audiobook = await audiobookCacheService.getAudiobook(id);
+ }
+
+ // Get chapters and audio files from API (these change less frequently)
+ let absBook;
+ try {
+ absBook = await audiobookshelfService.getAudiobook(id);
+ } catch (apiError: any) {
+ console.warn(
+ ` Failed to fetch live data from Audiobookshelf for ${id}, using cached data only:`,
+ apiError.message
+ );
+ // Continue with cached data only if API call fails
+ absBook = { media: { chapters: [], audioFiles: [] } };
+ }
+
+ // Get user's progress
+ const progress = await prisma.audiobookProgress.findUnique({
+ where: {
+ userId_audiobookshelfId: {
+ userId: req.user!.id,
+ audiobookshelfId: id,
+ },
+ },
+ });
+
+ const response = {
+ id: audiobook.id,
+ title: audiobook.title,
+ author: audiobook.author || "Unknown Author",
+ narrator: audiobook.narrator,
+ description: audiobook.description,
+ coverUrl:
+ audiobook.localCoverPath || audiobook.coverUrl
+ ? `/audiobooks/${audiobook.id}/cover`
+ : null,
+ duration: audiobook.duration || 0,
+ chapters: absBook.media?.chapters || [],
+ audioFiles: absBook.media?.audioFiles || [],
+ libraryId: audiobook.libraryId,
+ progress: progress
+ ? {
+ currentTime: progress.currentTime,
+ progress:
+ progress.duration > 0
+ ? (progress.currentTime / progress.duration) * 100
+ : 0,
+ isFinished: progress.isFinished,
+ lastPlayedAt: progress.lastPlayedAt,
+ }
+ : null,
+ };
+
+ res.json(response);
+ } catch (error: any) {
+ console.error("Error fetching audiobook__", error);
+ res.status(500).json({
+ error: "Failed to fetch audiobook",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /audiobooks/:id/stream
+ * Proxy the audiobook stream with authentication
+ */
+router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
+ try {
+ console.log(
+ `[Audiobook Stream] Request for audiobook: ${req.params.id}`
+ );
+ console.log(`[Audiobook Stream] User: ${req.user?.id || "unknown"}`);
+
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import("../utils/systemSettings");
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ console.log("[Audiobook Stream] Audiobookshelf not enabled");
+ return res
+ .status(503)
+ .json({ error: "Audiobookshelf is not configured" });
+ }
+
+ const { id } = req.params;
+ const rangeHeader = req.headers.range as string | undefined;
+
+ console.log(
+ `[Audiobook Stream] Fetching stream for ${id}, range: ${
+ rangeHeader || "none"
+ }`
+ );
+
+ const { stream, headers, status } =
+ await audiobookshelfService.streamAudiobook(id, rangeHeader);
+
+ console.log(
+ `[Audiobook Stream] Got stream, status: ${status}, content-type: ${headers["content-type"]}`
+ );
+
+ const responseStatus = status || (rangeHeader ? 206 : 200);
+ res.status(responseStatus);
+
+ // Set content type - ensure it's audio
+ const contentType = headers["content-type"] || "audio/mpeg";
+ res.setHeader("Content-Type", contentType);
+
+ // Set other headers
+ if (headers["content-length"]) {
+ res.setHeader("Content-Length", headers["content-length"]);
+ }
+ if (headers["accept-ranges"]) {
+ res.setHeader("Accept-Ranges", headers["accept-ranges"]);
+ } else {
+ res.setHeader("Accept-Ranges", "bytes");
+ }
+ if (headers["content-range"]) {
+ res.setHeader("Content-Range", headers["content-range"]);
+ }
+
+ res.setHeader("Cache-Control", "public, max-age=0");
+
+ // Clean up upstream stream when client disconnects (e.g., skips track, closes browser)
+ res.on("close", () => {
+ if (!stream.destroyed) {
+ stream.destroy();
+ }
+ });
+
+ stream.pipe(res);
+
+ stream.on("error", (error: any) => {
+ console.error("[Audiobook Stream] Stream error:", error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: "Failed to stream audiobook",
+ message: error.message,
+ });
+ } else {
+ res.end();
+ }
+ });
+ } catch (error: any) {
+ console.error("[Audiobook Stream] Error:", error.message);
+ res.status(500).json({
+ error: "Failed to stream audiobook",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * POST /audiobooks/:id/progress
+ * Update playback progress for an audiobook
+ */
+router.post(
+ "/:id/progress",
+ requireAuthOrToken,
+ apiLimiter,
+ async (req, res) => {
+ try {
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json({
+ success: false,
+ message: "Audiobookshelf is not configured",
+ });
+ }
+
+ const { id } = req.params;
+ const {
+ currentTime: rawCurrentTime,
+ duration: rawDuration,
+ isFinished,
+ } = req.body;
+
+ const currentTime =
+ typeof rawCurrentTime === "number" &&
+ Number.isFinite(rawCurrentTime)
+ ? Math.max(0, rawCurrentTime)
+ : 0;
+ const durationValue =
+ typeof rawDuration === "number" && Number.isFinite(rawDuration)
+ ? Math.max(rawDuration, 0)
+ : 0;
+
+ console.log(`\n [AUDIOBOOK PROGRESS] Received update:`);
+ console.log(` User: ${req.user!.username}`);
+ console.log(` Audiobook ID: ${id}`);
+ console.log(
+ ` Current Time: ${currentTime}s (${Math.floor(
+ currentTime / 60
+ )} mins)`
+ );
+ console.log(
+ ` Duration: ${durationValue}s (${Math.floor(
+ durationValue / 60
+ )} mins)`
+ );
+ if (durationValue > 0) {
+ console.log(
+ ` Progress: ${(
+ (currentTime / durationValue) *
+ 100
+ ).toFixed(1)}%`
+ );
+ } else {
+ console.log(" Progress: duration unknown");
+ }
+ console.log(` Finished: ${!!isFinished}`);
+
+ // Pull cached metadata to avoid hitting Audiobookshelf for every update
+ const [cachedAudiobook, existingProgress] = await Promise.all([
+ prisma.audiobook.findUnique({
+ where: { id },
+ select: {
+ title: true,
+ author: true,
+ coverUrl: true,
+ duration: true,
+ libraryId: true,
+ localCoverPath: true,
+ },
+ }),
+ prisma.audiobookProgress.findUnique({
+ where: {
+ userId_audiobookshelfId: {
+ userId: req.user!.id,
+ audiobookshelfId: id,
+ },
+ },
+ }),
+ ]);
+
+ const fallbackDuration =
+ durationValue ||
+ cachedAudiobook?.duration ||
+ existingProgress?.duration ||
+ 0;
+
+ const metadataTitle =
+ cachedAudiobook?.title ||
+ existingProgress?.title ||
+ "Unknown Title";
+ const metadataAuthor =
+ cachedAudiobook?.author ||
+ existingProgress?.author ||
+ "Unknown Author";
+ const metadataCover =
+ cachedAudiobook?.coverUrl || existingProgress?.coverUrl || null;
+
+ // Update progress in our database
+ const progress = await prisma.audiobookProgress.upsert({
+ where: {
+ userId_audiobookshelfId: {
+ userId: req.user!.id,
+ audiobookshelfId: id,
+ },
+ },
+ create: {
+ userId: req.user!.id,
+ audiobookshelfId: id,
+ title: metadataTitle,
+ author: metadataAuthor,
+ coverUrl: metadataCover,
+ currentTime,
+ duration: fallbackDuration,
+ isFinished: !!isFinished,
+ lastPlayedAt: new Date(),
+ },
+ update: {
+ title: metadataTitle,
+ author: metadataAuthor,
+ coverUrl: metadataCover,
+ currentTime,
+ duration: fallbackDuration,
+ isFinished: !!isFinished,
+ lastPlayedAt: new Date(),
+ },
+ });
+
+ console.log(` Progress saved to database`);
+
+ // Also update progress in Audiobookshelf
+ try {
+ await audiobookshelfService.updateProgress(
+ id,
+ currentTime,
+ fallbackDuration,
+ isFinished
+ );
+ console.log(` Progress synced to Audiobookshelf`);
+ } catch (error) {
+ console.error(
+ "Failed to sync progress to Audiobookshelf:",
+ error
+ );
+ // Continue anyway - local progress is saved
+ }
+
+ res.json({
+ success: true,
+ progress: {
+ currentTime: progress.currentTime,
+ progress:
+ progress.duration > 0
+ ? (progress.currentTime / progress.duration) * 100
+ : 0,
+ isFinished: progress.isFinished,
+ },
+ });
+ } catch (error: any) {
+ console.error("Error updating progress:", error);
+ res.status(500).json({
+ error: "Failed to update progress",
+ message: error.message,
+ });
+ }
+ }
+);
+
+/**
+ * DELETE /audiobooks/:id/progress
+ * Remove/reset progress for an audiobook
+ */
+router.delete(
+ "/:id/progress",
+ requireAuthOrToken,
+ apiLimiter,
+ async (req, res) => {
+ try {
+ // Check if Audiobookshelf is enabled
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfEnabled) {
+ return res.status(200).json({
+ success: false,
+ message: "Audiobookshelf is not configured",
+ });
+ }
+
+ const { id } = req.params;
+
+ console.log(`\n[AUDIOBOOK PROGRESS] Removing progress:`);
+ console.log(` User: ${req.user!.username}`);
+ console.log(` Audiobook ID: ${id}`);
+
+ // Delete progress from our database
+ await prisma.audiobookProgress.deleteMany({
+ where: {
+ userId: req.user!.id,
+ audiobookshelfId: id,
+ },
+ });
+
+ console.log(` Progress removed from database`);
+
+ // Also remove progress from Audiobookshelf
+ try {
+ await audiobookshelfService.updateProgress(id, 0, 0, false);
+ console.log(` Progress reset in Audiobookshelf`);
+ } catch (error) {
+ console.error(
+ "Failed to reset progress in Audiobookshelf:",
+ error
+ );
+ // Continue anyway - local progress is deleted
+ }
+
+ res.json({
+ success: true,
+ message: "Progress removed",
+ });
+ } catch (error: any) {
+ console.error("Error removing progress:", error);
+ res.status(500).json({
+ error: "Failed to remove progress",
+ message: error.message,
+ });
+ }
+ }
+);
+
+export default router;
diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts
new file mode 100644
index 0000000..1463d34
--- /dev/null
+++ b/backend/src/routes/auth.ts
@@ -0,0 +1,532 @@
+import { Router } from "express";
+import bcrypt from "bcrypt";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+import speakeasy from "speakeasy";
+import QRCode from "qrcode";
+import crypto from "crypto";
+import { requireAuth, requireAdmin, generateToken } from "../middleware/auth";
+import { encrypt, decrypt } from "../utils/encryption";
+
+const router = Router();
+
+const loginSchema = z.object({
+ username: z.string().min(1),
+ password: z.string().min(1),
+});
+
+// Use shared encryption module for 2FA secrets
+const encrypt2FASecret = encrypt;
+const decrypt2FASecret = decrypt;
+
+/**
+ * @openapi
+ * /auth/login:
+ * post:
+ * summary: Login with username and password
+ * tags: [Authentication]
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - username
+ * - password
+ * properties:
+ * username:
+ * type: string
+ * password:
+ * type: string
+ * format: password
+ * responses:
+ * 200:
+ * description: Login successful
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/User'
+ * 401:
+ * description: Invalid credentials
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+// POST /auth/login
+router.post("/login", async (req, res) => {
+ try {
+ const { username, password } = loginSchema.parse(req.body);
+ const { token } = req.body; // 2FA token if provided
+
+ const user = await prisma.user.findUnique({ where: { username } });
+ if (!user) {
+ return res.status(401).json({ error: "Invalid credentials" });
+ }
+
+ const valid = await bcrypt.compare(password, user.passwordHash);
+ if (!valid) {
+ return res.status(401).json({ error: "Invalid credentials" });
+ }
+
+ // Check if 2FA is enabled
+ if (user.twoFactorEnabled && user.twoFactorSecret) {
+ if (!token) {
+ return res.status(200).json({
+ requires2FA: true,
+ message: "2FA token required",
+ userId: user.id, // Send userId for next 2FA request
+ });
+ }
+
+ // Check if it's a recovery code
+ const isRecoveryCode = /^[A-F0-9]{8}$/i.test(token);
+
+ if (isRecoveryCode && user.twoFactorRecoveryCodes) {
+ const encryptedCodes = user.twoFactorRecoveryCodes;
+ const decryptedCodes = decrypt2FASecret(encryptedCodes);
+ const hashedCodes = decryptedCodes.split(",");
+
+ const providedHash = crypto
+ .createHash("sha256")
+ .update(token.toUpperCase())
+ .digest("hex");
+
+ const codeIndex = hashedCodes.indexOf(providedHash);
+ if (codeIndex === -1) {
+ return res.status(401).json({ error: "Invalid recovery code" });
+ }
+
+ hashedCodes.splice(codeIndex, 1);
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { twoFactorRecoveryCodes: encrypt2FASecret(hashedCodes.join(",")) },
+ });
+ } else {
+ // Verify TOTP token
+ const secret = decrypt2FASecret(user.twoFactorSecret);
+ const verified = speakeasy.totp.verify({
+ secret,
+ encoding: "base32",
+ token,
+ window: 2,
+ });
+
+ if (!verified) {
+ return res.status(401).json({ error: "Invalid 2FA token" });
+ }
+ }
+ }
+
+ // Generate JWT token
+ const jwtToken = generateToken({
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ });
+
+ res.json({
+ token: jwtToken,
+ user: {
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ },
+ });
+ } catch (err) {
+ if (err instanceof z.ZodError) {
+ return res.status(400).json({ error: "Invalid request", details: err.errors });
+ }
+ console.error("Login error:", err);
+ res.status(500).json({ error: "Internal error" });
+ }
+});
+
+// POST /auth/logout - JWT is stateless, logout is handled client-side
+router.post("/logout", (req, res) => {
+ // With JWT, logout is handled by client removing the token
+ // No server-side session to destroy
+ res.json({ message: "Logged out" });
+});
+
+/**
+ * @openapi
+ * /auth/me:
+ * get:
+ * summary: Get current authenticated user
+ * tags: [Authentication]
+ * security:
+ * - sessionAuth: []
+ * responses:
+ * 200:
+ * description: Current user information
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/User'
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+// GET /auth/me
+router.get("/me", requireAuth, async (req, res) => {
+ const user = await prisma.user.findUnique({
+ where: { id: req.user!.id },
+ select: {
+ id: true,
+ username: true,
+ role: true,
+ onboardingComplete: true,
+ enrichmentSettings: true,
+ createdAt: true,
+ },
+ });
+
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ res.json(user);
+});
+
+// POST /auth/change-password
+router.post("/change-password", requireAuth, async (req, res) => {
+ try {
+ const { currentPassword, newPassword } = req.body;
+
+ if (!currentPassword || !newPassword) {
+ return res
+ .status(400)
+ .json({ error: "Current and new password are required" });
+ }
+
+ if (newPassword.length < 6) {
+ return res
+ .status(400)
+ .json({ error: "New password must be at least 6 characters" });
+ }
+
+ // Verify current password
+ const user = await prisma.user.findUnique({
+ where: { id: req.user!.id },
+ });
+
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ const valid = await bcrypt.compare(currentPassword, user.passwordHash);
+ if (!valid) {
+ return res
+ .status(401)
+ .json({ error: "Current password is incorrect" });
+ }
+
+ // Update password
+ const newPasswordHash = await bcrypt.hash(newPassword, 10);
+ await prisma.user.update({
+ where: { id: req.user!.id },
+ data: { passwordHash: newPasswordHash },
+ });
+
+ res.json({ message: "Password changed successfully" });
+ } catch (error) {
+ console.error("Change password error:", error);
+ res.status(500).json({ error: "Failed to change password" });
+ }
+});
+
+// GET /auth/users (Admin only)
+router.get("/users", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const users = await prisma.user.findMany({
+ select: {
+ id: true,
+ username: true,
+ role: true,
+ onboardingComplete: true,
+ createdAt: true,
+ },
+ orderBy: { createdAt: "asc" },
+ });
+
+ res.json(users);
+ } catch (error) {
+ console.error("Get users error:", error);
+ res.status(500).json({ error: "Failed to get users" });
+ }
+});
+
+// POST /auth/create-user (Admin only)
+router.post("/create-user", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { username, password, role } = req.body;
+
+ if (!username || !password) {
+ return res
+ .status(400)
+ .json({ error: "Username and password are required" });
+ }
+
+ if (password.length < 6) {
+ return res
+ .status(400)
+ .json({ error: "Password must be at least 6 characters" });
+ }
+
+ if (role && !["user", "admin"].includes(role)) {
+ return res.status(400).json({ error: "Invalid role" });
+ }
+
+ // Check if username exists
+ const existing = await prisma.user.findUnique({
+ where: { username },
+ });
+
+ if (existing) {
+ return res.status(400).json({ error: "Username already taken" });
+ }
+
+ // Create user
+ const passwordHash = await bcrypt.hash(password, 10);
+ const user = await prisma.user.create({
+ data: {
+ username,
+ passwordHash,
+ role: role || "user",
+ onboardingComplete: true, // Skip onboarding for created users
+ },
+ });
+
+ // Create default user settings
+ await prisma.userSettings.create({
+ data: {
+ userId: user.id,
+ playbackQuality: "original",
+ wifiOnly: false,
+ offlineEnabled: false,
+ maxCacheSizeMb: 10240,
+ },
+ });
+
+ res.json({
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ createdAt: user.createdAt,
+ });
+ } catch (error) {
+ console.error("Create user error:", error);
+ res.status(500).json({ error: "Failed to create user" });
+ }
+});
+
+// DELETE /auth/users/:id (Admin only)
+router.delete("/users/:id", requireAuth, requireAdmin, async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ // Prevent deleting yourself
+ if (id === req.user!.id) {
+ return res
+ .status(400)
+ .json({ error: "Cannot delete your own account" });
+ }
+
+ // Delete user (cascade will handle related data)
+ await prisma.user.delete({
+ where: { id },
+ });
+
+ res.json({ message: "User deleted successfully" });
+ } catch (error: any) {
+ console.error("Delete user error:", error);
+ if (error.code === "P2025") {
+ return res.status(404).json({ error: "User not found" });
+ }
+ res.status(500).json({ error: "Failed to delete user" });
+ }
+});
+
+// POST /auth/2fa/setup - Generate 2FA secret and QR code
+router.post("/2fa/setup", requireAuth, async (req, res) => {
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: req.user!.id },
+ select: { username: true, twoFactorEnabled: true },
+ });
+
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ if (user.twoFactorEnabled) {
+ return res.status(400).json({ error: "2FA is already enabled" });
+ }
+
+ // Generate secret
+ const secret = speakeasy.generateSecret({
+ name: `Lidify (${user.username})`,
+ issuer: "Lidify",
+ });
+
+ // Generate QR code
+ const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!);
+
+ res.json({
+ secret: secret.base32,
+ qrCode: qrCodeDataUrl,
+ });
+ } catch (error) {
+ console.error("2FA setup error:", error);
+ res.status(500).json({ error: "Failed to setup 2FA" });
+ }
+});
+
+// POST /auth/2fa/enable - Verify token and enable 2FA
+router.post("/2fa/enable", requireAuth, async (req, res) => {
+ try {
+ const { secret, token } = req.body;
+
+ if (!secret || !token) {
+ return res
+ .status(400)
+ .json({ error: "Secret and token are required" });
+ }
+
+ // Verify the token with the secret
+ const verified = speakeasy.totp.verify({
+ secret,
+ encoding: "base32",
+ token,
+ window: 2,
+ });
+
+ if (!verified) {
+ return res
+ .status(401)
+ .json({ error: "Invalid token. Please try again." });
+ }
+
+ // Generate 10 recovery codes
+ const recoveryCodes: string[] = [];
+ const hashedRecoveryCodes: string[] = [];
+
+ for (let i = 0; i < 10; i++) {
+ // Generate 8-character alphanumeric code
+ const code = crypto.randomBytes(4).toString("hex").toUpperCase();
+ recoveryCodes.push(code);
+ // Hash the code before storing
+ hashedRecoveryCodes.push(
+ crypto.createHash("sha256").update(code).digest("hex")
+ );
+ }
+
+ // Encrypt the hashed codes for storage
+ const encryptedRecoveryCodes = encrypt2FASecret(
+ hashedRecoveryCodes.join(",")
+ );
+
+ // Encrypt and save the secret
+ const encryptedSecret = encrypt2FASecret(secret);
+ await prisma.user.update({
+ where: { id: req.user!.id },
+ data: {
+ twoFactorEnabled: true,
+ twoFactorSecret: encryptedSecret,
+ twoFactorRecoveryCodes: encryptedRecoveryCodes,
+ },
+ });
+
+ // Return the plain recovery codes to the user (only time they'll see them)
+ res.json({
+ message: "2FA enabled successfully",
+ recoveryCodes: recoveryCodes,
+ });
+ } catch (error) {
+ console.error("2FA enable error:", error);
+ res.status(500).json({ error: "Failed to enable 2FA" });
+ }
+});
+
+// POST /auth/2fa/disable - Disable 2FA
+router.post("/2fa/disable", requireAuth, async (req, res) => {
+ try {
+ const { password, token } = req.body;
+
+ if (!password || !token) {
+ return res
+ .status(400)
+ .json({ error: "Password and current 2FA token are required" });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { id: req.user!.id },
+ });
+
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ // Verify password
+ const validPassword = await bcrypt.compare(password, user.passwordHash);
+ if (!validPassword) {
+ return res.status(401).json({ error: "Invalid password" });
+ }
+
+ // Verify 2FA token
+ if (user.twoFactorSecret) {
+ const secret = decrypt2FASecret(user.twoFactorSecret);
+ const verified = speakeasy.totp.verify({
+ secret,
+ encoding: "base32",
+ token,
+ window: 2,
+ });
+
+ if (!verified) {
+ return res.status(401).json({ error: "Invalid 2FA token" });
+ }
+ }
+
+ // Disable 2FA
+ await prisma.user.update({
+ where: { id: req.user!.id },
+ data: {
+ twoFactorEnabled: false,
+ twoFactorSecret: null,
+ twoFactorRecoveryCodes: null,
+ },
+ });
+
+ res.json({ message: "2FA disabled successfully" });
+ } catch (error) {
+ console.error("2FA disable error:", error);
+ res.status(500).json({ error: "Failed to disable 2FA" });
+ }
+});
+
+// GET /auth/2fa/status - Check if 2FA is enabled
+router.get("/2fa/status", requireAuth, async (req, res) => {
+ try {
+ const user = await prisma.user.findUnique({
+ where: { id: req.user!.id },
+ select: { twoFactorEnabled: true },
+ });
+
+ if (!user) {
+ return res.status(404).json({ error: "User not found" });
+ }
+
+ res.json({ enabled: user.twoFactorEnabled });
+ } catch (error) {
+ console.error("2FA status error:", error);
+ res.status(500).json({ error: "Failed to get 2FA status" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/browse.ts b/backend/src/routes/browse.ts
new file mode 100644
index 0000000..012baf2
--- /dev/null
+++ b/backend/src/routes/browse.ts
@@ -0,0 +1,377 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { spotifyService } from "../services/spotify";
+import { deezerService, DeezerPlaylistPreview, DeezerRadioStation } from "../services/deezer";
+
+const router = Router();
+
+// All routes require authentication
+router.use(requireAuthOrToken);
+
+/**
+ * Unified playlist preview type
+ */
+interface PlaylistPreview {
+ id: string;
+ source: "deezer" | "spotify";
+ type: "playlist" | "radio";
+ title: string;
+ description: string | null;
+ creator: string;
+ imageUrl: string | null;
+ trackCount: number;
+ url: string;
+}
+
+/**
+ * Convert Deezer playlist to unified format
+ */
+function deezerPlaylistToUnified(playlist: DeezerPlaylistPreview): PlaylistPreview {
+ return {
+ id: playlist.id,
+ source: "deezer",
+ type: "playlist",
+ title: playlist.title,
+ description: playlist.description,
+ creator: playlist.creator,
+ imageUrl: playlist.imageUrl,
+ trackCount: playlist.trackCount,
+ url: `https://www.deezer.com/playlist/${playlist.id}`,
+ };
+}
+
+/**
+ * Convert Deezer radio to unified format
+ */
+function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview {
+ return {
+ id: radio.id,
+ source: "deezer",
+ type: "radio",
+ title: radio.title,
+ description: radio.description,
+ creator: "Deezer",
+ imageUrl: radio.imageUrl,
+ trackCount: 0, // Radio tracks are dynamic
+ url: `https://www.deezer.com/radio-${radio.id}`,
+ };
+}
+
+// ============================================
+// Playlist Endpoints
+// ============================================
+
+/**
+ * GET /api/browse/playlists/featured
+ * Get featured/chart playlists from Deezer
+ */
+router.get("/playlists/featured", async (req, res) => {
+ try {
+ const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
+ console.log(`[Browse] Fetching featured playlists (limit: ${limit})...`);
+
+ const playlists = await deezerService.getFeaturedPlaylists(limit);
+ console.log(`[Browse] Got ${playlists.length} Deezer playlists`);
+
+ res.json({
+ playlists: playlists.map(deezerPlaylistToUnified),
+ total: playlists.length,
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Browse featured playlists error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch playlists" });
+ }
+});
+
+/**
+ * GET /api/browse/playlists/search
+ * Search for playlists on Deezer
+ */
+router.get("/playlists/search", async (req, res) => {
+ try {
+ const query = req.query.q as string;
+ if (!query || query.length < 2) {
+ return res.status(400).json({ error: "Search query must be at least 2 characters" });
+ }
+
+ const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
+ console.log(`[Browse] Searching playlists for "${query}"...`);
+
+ const playlists = await deezerService.searchPlaylists(query, limit);
+ console.log(`[Browse] Search "${query}": ${playlists.length} results`);
+
+ res.json({
+ playlists: playlists.map(deezerPlaylistToUnified),
+ total: playlists.length,
+ query,
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Browse search playlists error:", error);
+ res.status(500).json({ error: error.message || "Failed to search playlists" });
+ }
+});
+
+/**
+ * GET /api/browse/playlists/:id
+ * Get full details of a Deezer playlist
+ */
+router.get("/playlists/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+ const playlist = await deezerService.getPlaylist(id);
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ res.json({
+ ...playlist,
+ source: "deezer",
+ url: `https://www.deezer.com/playlist/${id}`,
+ });
+ } catch (error: any) {
+ console.error("Playlist fetch error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch playlist" });
+ }
+});
+
+// ============================================
+// Radio Endpoints
+// ============================================
+
+/**
+ * GET /api/browse/radios
+ * Get all radio stations (mood/theme based mixes)
+ */
+router.get("/radios", async (req, res) => {
+ try {
+ console.log("[Browse] Fetching radio stations...");
+ const radios = await deezerService.getRadioStations();
+
+ res.json({
+ radios: radios.map(deezerRadioToUnified),
+ total: radios.length,
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Browse radios error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch radios" });
+ }
+});
+
+/**
+ * GET /api/browse/radios/by-genre
+ * Get radio stations organized by genre
+ */
+router.get("/radios/by-genre", async (req, res) => {
+ try {
+ console.log("[Browse] Fetching radios by genre...");
+ const genresWithRadios = await deezerService.getRadiosByGenre();
+
+ // Transform to include unified format
+ const result = genresWithRadios.map(genre => ({
+ id: genre.id,
+ name: genre.name,
+ radios: genre.radios.map(deezerRadioToUnified),
+ }));
+
+ res.json({
+ genres: result,
+ total: result.length,
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Browse radios by genre error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch radios" });
+ }
+});
+
+/**
+ * GET /api/browse/radios/:id
+ * Get tracks from a radio station (as playlist format for import)
+ */
+router.get("/radios/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+ console.log(`[Browse] Fetching radio ${id} tracks...`);
+
+ const radioPlaylist = await deezerService.getRadioTracks(id);
+
+ if (!radioPlaylist) {
+ return res.status(404).json({ error: "Radio station not found" });
+ }
+
+ res.json({
+ ...radioPlaylist,
+ source: "deezer",
+ type: "radio",
+ });
+ } catch (error: any) {
+ console.error("Radio tracks error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch radio tracks" });
+ }
+});
+
+// ============================================
+// Genre Endpoints
+// ============================================
+
+/**
+ * GET /api/browse/genres
+ * Get all available genres
+ */
+router.get("/genres", async (req, res) => {
+ try {
+ console.log("[Browse] Fetching genres...");
+ const genres = await deezerService.getGenres();
+
+ res.json({
+ genres,
+ total: genres.length,
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Browse genres error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch genres" });
+ }
+});
+
+/**
+ * GET /api/browse/genres/:id
+ * Get content for a specific genre (playlists + radios)
+ */
+router.get("/genres/:id", async (req, res) => {
+ try {
+ const genreId = parseInt(req.params.id);
+ if (isNaN(genreId)) {
+ return res.status(400).json({ error: "Invalid genre ID" });
+ }
+
+ console.log(`[Browse] Fetching content for genre ${genreId}...`);
+ const content = await deezerService.getEditorialContent(genreId);
+
+ res.json({
+ genreId,
+ playlists: content.playlists.map(deezerPlaylistToUnified),
+ radios: content.radios.map(deezerRadioToUnified),
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Genre content error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch genre content" });
+ }
+});
+
+/**
+ * GET /api/browse/genres/:id/playlists
+ * Get playlists for a specific genre (by name search)
+ */
+router.get("/genres/:id/playlists", async (req, res) => {
+ try {
+ const genreId = parseInt(req.params.id);
+ const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
+
+ // Get genre name first
+ const genres = await deezerService.getGenres();
+ const genre = genres.find(g => g.id === genreId);
+
+ if (!genre) {
+ return res.status(404).json({ error: "Genre not found" });
+ }
+
+ const playlists = await deezerService.getGenrePlaylists(genre.name, limit);
+
+ res.json({
+ playlists: playlists.map(deezerPlaylistToUnified),
+ total: playlists.length,
+ genre: genre.name,
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Genre playlists error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch genre playlists" });
+ }
+});
+
+// ============================================
+// URL Parsing (supports both Spotify & Deezer)
+// ============================================
+
+/**
+ * POST /api/browse/playlists/parse
+ * Parse a Spotify or Deezer URL and return playlist info
+ * This is the main entry point for URL-based imports
+ */
+router.post("/playlists/parse", async (req, res) => {
+ try {
+ const { url } = req.body;
+ if (!url) {
+ return res.status(400).json({ error: "URL is required" });
+ }
+
+ // Try Deezer first (our primary source)
+ const deezerParsed = deezerService.parseUrl(url);
+ if (deezerParsed && deezerParsed.type === "playlist") {
+ return res.json({
+ source: "deezer",
+ type: "playlist",
+ id: deezerParsed.id,
+ url: `https://www.deezer.com/playlist/${deezerParsed.id}`,
+ });
+ }
+
+ // Try Spotify (still supported for URL imports)
+ const spotifyParsed = spotifyService.parseUrl(url);
+ if (spotifyParsed && spotifyParsed.type === "playlist") {
+ return res.json({
+ source: "spotify",
+ type: "playlist",
+ id: spotifyParsed.id,
+ url: `https://open.spotify.com/playlist/${spotifyParsed.id}`,
+ });
+ }
+
+ return res.status(400).json({
+ error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL."
+ });
+ } catch (error: any) {
+ console.error("Parse URL error:", error);
+ res.status(500).json({ error: error.message || "Failed to parse URL" });
+ }
+});
+
+// ============================================
+// Combined Browse Endpoint (for frontend convenience)
+// ============================================
+
+/**
+ * GET /api/browse/all
+ * Get a combined view of featured content (playlists, genres)
+ * Note: Radio stations are now internal (library-based), not from Deezer
+ */
+router.get("/all", async (req, res) => {
+ try {
+ console.log("[Browse] Fetching browse content (playlists + genres)...");
+
+ // Only fetch playlists and genres - radios are now internal library-based
+ const [playlists, genres] = await Promise.all([
+ deezerService.getFeaturedPlaylists(200),
+ deezerService.getGenres(),
+ ]);
+
+ res.json({
+ playlists: playlists.map(deezerPlaylistToUnified),
+ radios: [], // Radio stations are now internal (use /api/library/radio)
+ genres,
+ radiosByGenre: [], // Deprecated - use internal radios
+ source: "deezer",
+ });
+ } catch (error: any) {
+ console.error("Browse all error:", error);
+ res.status(500).json({ error: error.message || "Failed to fetch browse content" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/deviceLink.ts b/backend/src/routes/deviceLink.ts
new file mode 100644
index 0000000..85a45af
--- /dev/null
+++ b/backend/src/routes/deviceLink.ts
@@ -0,0 +1,232 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import crypto from "crypto";
+
+const router = Router();
+
+// Generate a random 6-character alphanumeric code
+function generateLinkCode(): string {
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Exclude similar looking chars
+ let code = "";
+ for (let i = 0; i < 6; i++) {
+ code += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return code;
+}
+
+// Generate API key
+function generateApiKey(): string {
+ return crypto.randomBytes(32).toString("hex");
+}
+
+// POST /device-link/generate - Generate a new device link code (requires auth)
+router.post("/generate", requireAuthOrToken, async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ // Delete any existing unused codes for this user
+ await prisma.deviceLinkCode.deleteMany({
+ where: {
+ userId,
+ usedAt: null,
+ },
+ });
+
+ // Generate a unique code
+ let code: string;
+ let attempts = 0;
+ do {
+ code = generateLinkCode();
+ attempts++;
+ if (attempts > 10) {
+ return res.status(500).json({ error: "Failed to generate unique code" });
+ }
+ } while (
+ await prisma.deviceLinkCode.findUnique({
+ where: { code },
+ })
+ );
+
+ // Create the code with 5-minute expiry
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
+ const linkCode = await prisma.deviceLinkCode.create({
+ data: {
+ code,
+ userId,
+ expiresAt,
+ },
+ });
+
+ res.json({
+ code: linkCode.code,
+ expiresAt: linkCode.expiresAt,
+ expiresIn: 300, // 5 minutes in seconds
+ });
+ } catch (error) {
+ console.error("Generate device link code error:", error);
+ res.status(500).json({ error: "Failed to generate device link code" });
+ }
+});
+
+// POST /device-link/verify - Verify a code and get API key (no auth required)
+router.post("/verify", async (req, res) => {
+ try {
+ const { code, deviceName } = req.body;
+
+ if (!code || typeof code !== "string") {
+ return res.status(400).json({ error: "Code is required" });
+ }
+
+ // Find the code
+ const linkCode = await prisma.deviceLinkCode.findUnique({
+ where: { code: code.toUpperCase() },
+ include: { user: true },
+ });
+
+ if (!linkCode) {
+ return res.status(404).json({ error: "Invalid code" });
+ }
+
+ if (linkCode.usedAt) {
+ return res.status(400).json({ error: "Code already used" });
+ }
+
+ if (new Date() > linkCode.expiresAt) {
+ return res.status(400).json({ error: "Code expired" });
+ }
+
+ // Generate API key for this device
+ const apiKey = generateApiKey();
+ const createdApiKey = await prisma.apiKey.create({
+ data: {
+ userId: linkCode.userId,
+ key: apiKey,
+ name: deviceName || "Mobile Device",
+ },
+ });
+
+ // Mark the link code as used
+ await prisma.deviceLinkCode.update({
+ where: { id: linkCode.id },
+ data: {
+ usedAt: new Date(),
+ deviceName: deviceName || "Mobile Device",
+ apiKeyId: createdApiKey.id,
+ },
+ });
+
+ res.json({
+ success: true,
+ apiKey,
+ userId: linkCode.userId,
+ username: linkCode.user.username,
+ });
+ } catch (error) {
+ console.error("Verify device link code error:", error);
+ res.status(500).json({ error: "Failed to verify device link code" });
+ }
+});
+
+// GET /device-link/status/:code - Poll for code usage status (no auth required)
+router.get("/status/:code", async (req, res) => {
+ try {
+ const { code } = req.params;
+
+ const linkCode = await prisma.deviceLinkCode.findUnique({
+ where: { code: code.toUpperCase() },
+ });
+
+ if (!linkCode) {
+ return res.status(404).json({ error: "Invalid code" });
+ }
+
+ if (new Date() > linkCode.expiresAt && !linkCode.usedAt) {
+ return res.json({
+ status: "expired",
+ expiresAt: linkCode.expiresAt,
+ });
+ }
+
+ if (linkCode.usedAt) {
+ return res.json({
+ status: "used",
+ usedAt: linkCode.usedAt,
+ deviceName: linkCode.deviceName,
+ });
+ }
+
+ res.json({
+ status: "pending",
+ expiresAt: linkCode.expiresAt,
+ });
+ } catch (error) {
+ console.error("Check device link status error:", error);
+ res.status(500).json({ error: "Failed to check status" });
+ }
+});
+
+// GET /device-link/devices - List linked devices (requires auth)
+router.get("/devices", requireAuthOrToken, async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ const apiKeys = await prisma.apiKey.findMany({
+ where: { userId },
+ orderBy: { lastUsed: "desc" },
+ select: {
+ id: true,
+ name: true,
+ lastUsed: true,
+ createdAt: true,
+ },
+ });
+
+ res.json(apiKeys);
+ } catch (error) {
+ console.error("Get devices error:", error);
+ res.status(500).json({ error: "Failed to get devices" });
+ }
+});
+
+// DELETE /device-link/devices/:id - Revoke a device (requires auth)
+router.delete("/devices/:id", requireAuthOrToken, async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { id } = req.params;
+
+ const apiKey = await prisma.apiKey.findFirst({
+ where: { id, userId },
+ });
+
+ if (!apiKey) {
+ return res.status(404).json({ error: "Device not found" });
+ }
+
+ await prisma.apiKey.delete({
+ where: { id },
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error("Revoke device error:", error);
+ res.status(500).json({ error: "Failed to revoke device" });
+ }
+});
+
+export default router;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/routes/discover.ts b/backend/src/routes/discover.ts
new file mode 100644
index 0000000..145717b
--- /dev/null
+++ b/backend/src/routes/discover.ts
@@ -0,0 +1,1793 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { lastFmService } from "../services/lastfm";
+import { startOfWeek, endOfWeek } from "date-fns";
+import axios from "axios";
+import fs from "fs";
+import path from "path";
+import { config } from "../config";
+
+// Static imports for performance
+import { discoverQueue, scanQueue } from "../workers/queues";
+import { getSystemSettings } from "../utils/systemSettings";
+import { lidarrService } from "../services/lidarr";
+
+const router = Router();
+
+router.use(requireAuthOrToken);
+
+// GET /discover/batch-status - Check if there's an active batch being processed
+router.get("/batch-status", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ // Find any active batch for this user
+ const activeBatch = await prisma.discoveryBatch.findFirst({
+ where: {
+ userId,
+ status: { in: ["downloading", "scanning"] },
+ },
+ include: {
+ jobs: {
+ select: {
+ status: true,
+ },
+ },
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ if (!activeBatch) {
+ return res.json({
+ active: false,
+ status: null,
+ progress: null,
+ });
+ }
+
+ const completedJobs = activeBatch.jobs.filter(j => j.status === "completed").length;
+ const failedJobs = activeBatch.jobs.filter(j => j.status === "failed" || j.status === "exhausted").length;
+ const totalJobs = activeBatch.jobs.length;
+ const progress = totalJobs > 0 ? Math.round(((completedJobs + failedJobs) / totalJobs) * 100) : 0;
+
+ res.json({
+ active: true,
+ status: activeBatch.status,
+ batchId: activeBatch.id,
+ progress,
+ completed: completedJobs,
+ failed: failedJobs,
+ total: totalJobs,
+ });
+ } catch (error) {
+ console.error("Get batch status error:", error);
+ res.status(500).json({ error: "Failed to get batch status" });
+ }
+});
+
+// POST /discover/generate - Generate new Discover Weekly playlist (using Bull queue)
+router.post("/generate", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ // Check for existing active batch
+ const existingBatch = await prisma.discoveryBatch.findFirst({
+ where: {
+ userId,
+ status: { in: ["downloading", "scanning"] },
+ },
+ });
+
+ if (existingBatch) {
+ return res.status(409).json({
+ error: "Generation already in progress",
+ batchId: existingBatch.id,
+ status: existingBatch.status,
+ });
+ }
+
+ console.log(`\n Queuing Discover Weekly generation for user ${userId}`);
+
+ // Add generation job to queue
+ const job = await discoverQueue.add({ userId });
+
+ res.json({
+ message: "Discover Weekly generation started",
+ jobId: job.id,
+ });
+ } catch (error) {
+ console.error("Generate Discover Weekly error:", error);
+ res.status(500).json({ error: "Failed to start generation" });
+ }
+});
+
+// GET /discover/generate/status/:jobId - Check generation job status
+router.get("/generate/status/:jobId", async (req, res) => {
+ try {
+ const job = await discoverQueue.getJob(req.params.jobId);
+
+ if (!job) {
+ return res.status(404).json({ error: "Job not found" });
+ }
+
+ const state = await job.getState();
+ const progress = job.progress();
+ const result = job.returnvalue;
+
+ res.json({
+ status: state,
+ progress,
+ result,
+ });
+ } catch (error) {
+ console.error("Get generation status error:", error);
+ res.status(500).json({ error: "Failed to get job status" });
+ }
+});
+
+// GET /discover/current - Get current week's Discover Weekly playlist
+router.get("/current", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
+ const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 }); // Sunday
+
+ // Get all discovery albums for this week with their tracks
+ const discoveryAlbums = await prisma.discoveryAlbum.findMany({
+ where: {
+ userId,
+ weekStartDate: weekStart,
+ status: { in: ["ACTIVE", "LIKED"] },
+ },
+ include: {
+ tracks: true, // DiscoveryTrack records (trackId is just a string, not a relation)
+ },
+ orderBy: { downloadedAt: "asc" },
+ });
+
+ // Get unavailable albums for this week (show full replacement chain)
+ const unavailableAlbums = await prisma.unavailableAlbum.findMany({
+ where: {
+ userId,
+ weekStartDate: weekStart,
+ },
+ orderBy: [
+ { originalAlbumId: "asc" }, // Group by original album
+ { attemptNumber: "asc" }, // Then sort by attempt number
+ ],
+ });
+
+ // Build track list from DiscoveryTrack records (the actual selected tracks)
+ const tracks = [];
+
+ for (const discoveryAlbum of discoveryAlbums) {
+ // If we have DiscoveryTrack records, use them (the actual selected tracks)
+ if (discoveryAlbum.tracks && discoveryAlbum.tracks.length > 0) {
+ // Fetch all tracks in one query using their IDs
+ const trackIds = discoveryAlbum.tracks
+ .map(dt => dt.trackId)
+ .filter((id): id is string => id !== null);
+
+ if (trackIds.length > 0) {
+ const libraryTracks = await prisma.track.findMany({
+ where: { id: { in: trackIds } },
+ include: { album: { include: { artist: true } } },
+ });
+
+ // Create a map for quick lookup
+ const trackMap = new Map(libraryTracks.map(t => [t.id, t]));
+
+ for (const dt of discoveryAlbum.tracks) {
+ const track = dt.trackId ? trackMap.get(dt.trackId) : null;
+ if (track) {
+ tracks.push({
+ id: track.id,
+ title: track.title,
+ artist: discoveryAlbum.artistName,
+ album: discoveryAlbum.albumTitle,
+ albumId: discoveryAlbum.rgMbid,
+ isLiked: discoveryAlbum.status === "LIKED",
+ likedAt: discoveryAlbum.likedAt,
+ similarity: discoveryAlbum.similarity,
+ tier: discoveryAlbum.tier,
+ coverUrl: track.album?.coverUrl,
+ available: true,
+ });
+ }
+ }
+ }
+ }
+
+ // Fallback: No DiscoveryTrack records or no valid trackIds, find ONE track from library
+ if (tracks.filter(t => t.album === discoveryAlbum.albumTitle).length === 0) {
+ const album = await prisma.album.findFirst({
+ where: {
+ title: discoveryAlbum.albumTitle,
+ artist: { name: discoveryAlbum.artistName },
+ },
+ include: {
+ artist: true,
+ tracks: { take: 1, orderBy: { trackNo: "asc" } },
+ },
+ });
+
+ if (album && album.tracks.length > 0) {
+ const track = album.tracks[0];
+ tracks.push({
+ id: track.id,
+ title: track.title,
+ artist: discoveryAlbum.artistName,
+ album: discoveryAlbum.albumTitle,
+ albumId: discoveryAlbum.rgMbid,
+ isLiked: discoveryAlbum.status === "LIKED",
+ likedAt: discoveryAlbum.likedAt,
+ similarity: discoveryAlbum.similarity,
+ tier: discoveryAlbum.tier,
+ coverUrl: album.coverUrl,
+ available: true,
+ });
+ } else {
+ // Album not in library yet (downloading/pending)
+ tracks.push({
+ id: `pending-${discoveryAlbum.id}`,
+ title: `${discoveryAlbum.albumTitle} (pending import)`,
+ artist: discoveryAlbum.artistName,
+ album: discoveryAlbum.albumTitle,
+ albumId: discoveryAlbum.rgMbid,
+ isLiked: discoveryAlbum.status === "LIKED",
+ likedAt: discoveryAlbum.likedAt,
+ similarity: discoveryAlbum.similarity,
+ tier: discoveryAlbum.tier,
+ coverUrl: null,
+ available: false,
+ isPending: true,
+ });
+ }
+ }
+ }
+
+ // Get the list of successfully downloaded album MBIDs from discoveryAlbums
+ const successfulMbids = new Set(discoveryAlbums.map(da => da.rgMbid));
+
+ // Filter unavailable albums:
+ // 1. Remove albums that successfully downloaded (have DiscoveryAlbum record)
+ // 2. Remove albums that the user now owns (in Album table)
+ const filteredUnavailable: typeof unavailableAlbums = [];
+ for (const album of unavailableAlbums) {
+ // Skip if this album successfully downloaded this week
+ if (successfulMbids.has(album.albumMbid)) {
+ continue;
+ }
+
+ // Skip if album exists in user's library by artist+title (normalized match)
+ const normalizedArtist = album.artistName.toLowerCase().trim();
+ const normalizedAlbum = album.albumTitle.toLowerCase()
+ .replace(/\(.*?\)/g, "") // Remove parenthetical content
+ .replace(/\[.*?\]/g, "") // Remove bracketed content
+ .trim();
+
+ const existsInLibrary = await prisma.album.findFirst({
+ where: {
+ OR: [
+ { rgMbid: album.albumMbid },
+ {
+ title: { contains: normalizedAlbum, mode: "insensitive" },
+ artist: { name: { contains: normalizedArtist, mode: "insensitive" } },
+ }
+ ]
+ }
+ });
+
+ if (existsInLibrary) {
+ continue; // User already owns this album, don't show as unavailable
+ }
+
+ filteredUnavailable.push(album);
+ }
+
+ // Format unavailable albums
+ const unavailable = filteredUnavailable.map((album) => ({
+ id: `unavailable-${album.id}`,
+ title: album.albumTitle,
+ artist: album.artistName,
+ album: album.albumTitle,
+ albumId: album.albumMbid,
+ similarity: album.similarity,
+ tier: album.tier,
+ previewUrl: album.previewUrl,
+ deezerTrackId: album.deezerTrackId,
+ deezerAlbumId: album.deezerAlbumId,
+ attemptNumber: album.attemptNumber,
+ originalAlbumId: album.originalAlbumId,
+ available: false,
+ }));
+
+ try {
+ console.log(`\nDiscover Weekly API Response:`);
+ console.log(` Total tracks: ${tracks.length}`);
+ console.log(` Unavailable albums: ${unavailable.length}`);
+ if (unavailable.length > 0 && unavailable.length <= 20) {
+ console.log(` Unavailable albums with previews:`);
+ unavailable.slice(0, 5).forEach((album, i) => {
+ console.log(
+ ` ${i + 1}. ${album.artist} - ${album.album} [${
+ album.previewUrl ? "HAS PREVIEW" : "NO PREVIEW"
+ }]`
+ );
+ });
+ if (unavailable.length > 5) {
+ console.log(` ... and ${unavailable.length - 5} more`);
+ }
+ }
+ } catch (err) {
+ console.error("Error logging discover response:", err);
+ }
+
+ res.json({
+ weekStart,
+ weekEnd,
+ tracks,
+ unavailable,
+ totalCount: tracks.length,
+ unavailableCount: unavailable.length,
+ });
+ } catch (error) {
+ console.error("Get current Discover Weekly error:", error);
+ res.status(500).json({
+ error: "Failed to get Discover Weekly playlist",
+ });
+ }
+});
+
+// POST /discover/like - Like a track (marks entire album for keeping)
+router.post("/like", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { albumId } = req.body;
+
+ if (!albumId) {
+ return res.status(400).json({ error: "albumId required" });
+ }
+
+ // Find the discovery album
+ const discoveryAlbum = await prisma.discoveryAlbum.findFirst({
+ where: {
+ userId,
+ rgMbid: albumId,
+ status: "ACTIVE",
+ },
+ });
+
+ if (!discoveryAlbum) {
+ return res
+ .status(404)
+ .json({ error: "Album not in active discovery" });
+ }
+
+ // Mark as liked (entire album will be kept)
+ await prisma.discoveryAlbum.update({
+ where: { id: discoveryAlbum.id },
+ data: {
+ status: "LIKED",
+ likedAt: new Date(),
+ },
+ });
+
+ // Remove discovery tag from the artist in Lidarr
+ // This prevents the artist from being deleted during cleanup
+ console.log(` Removing discovery tag from artist: ${discoveryAlbum.artistName}`);
+
+ // If artistMbid is a temp ID, we need to search Lidarr by artist name instead
+ if (discoveryAlbum.artistMbid && !discoveryAlbum.artistMbid.startsWith("temp-")) {
+ await lidarrService.removeDiscoveryTagByMbid(discoveryAlbum.artistMbid);
+ } else {
+ // Search Lidarr for the artist by name and remove tag
+ try {
+ const lidarrArtists = await lidarrService.getArtists();
+ const lidarrArtist = lidarrArtists.find(
+ a => a.artistName.toLowerCase() === discoveryAlbum.artistName.toLowerCase()
+ );
+
+ if (lidarrArtist) {
+ const tagId = await lidarrService.getOrCreateDiscoveryTag();
+ if (tagId && lidarrArtist.tags?.includes(tagId)) {
+ await lidarrService.removeTagsFromArtist(lidarrArtist.id, [tagId]);
+ console.log(` Removed discovery tag from ${lidarrArtist.artistName} (found by name)`);
+ }
+ } else {
+ console.log(` Artist ${discoveryAlbum.artistName} not found in Lidarr (may have been removed)`);
+ }
+ } catch (e: any) {
+ console.log(` Failed to remove discovery tag: ${e.message}`);
+ }
+ }
+
+ // Find the actual Album record and create OwnedAlbum so it appears in library immediately
+ // Match by artist name + album title since rgMbid may differ between DiscoveryAlbum and scanned Album
+ const dbAlbum = await prisma.album.findFirst({
+ where: {
+ OR: [
+ { rgMbid: albumId },
+ {
+ title: { equals: discoveryAlbum.albumTitle, mode: "insensitive" },
+ artist: { name: { equals: discoveryAlbum.artistName, mode: "insensitive" } },
+ },
+ ],
+ },
+ include: { artist: true },
+ });
+
+ if (dbAlbum) {
+ // Update album location to LIBRARY so it appears in owned view
+ await prisma.album.update({
+ where: { id: dbAlbum.id },
+ data: { location: "LIBRARY" },
+ });
+
+ // Create OwnedAlbum record if doesn't exist (makes it appear in "Owned" filter)
+ await prisma.ownedAlbum.upsert({
+ where: {
+ artistId_rgMbid: {
+ artistId: dbAlbum.artistId,
+ rgMbid: dbAlbum.rgMbid,
+ },
+ },
+ create: {
+ artistId: dbAlbum.artistId,
+ rgMbid: dbAlbum.rgMbid,
+ source: "discovery_liked",
+ },
+ update: {
+ source: "discovery_liked",
+ },
+ });
+ console.log(` ✓ Added liked album to library: ${dbAlbum.artist.name} - ${dbAlbum.title} (matched from discovery)`);
+ } else {
+ console.log(` [WARN] Could not find scanned album for: ${discoveryAlbum.artistName} - ${discoveryAlbum.albumTitle}`);
+ }
+
+ // Retroactively mark all plays from this album as DISCOVERY_KEPT
+ // Note: This requires getting tracks from the album first
+ const tracks = await prisma.discoveryTrack.findMany({
+ where: { discoveryAlbumId: discoveryAlbum.id },
+ select: { trackId: true },
+ });
+
+ const trackIds = tracks
+ .map((t) => t.trackId)
+ .filter((id): id is string => id !== null);
+
+ if (trackIds.length > 0) {
+ await prisma.play.updateMany({
+ where: {
+ userId,
+ trackId: { in: trackIds },
+ source: "DISCOVERY",
+ },
+ data: {
+ source: "DISCOVERY_KEPT",
+ },
+ });
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error("Like discovery album error:", error);
+ res.status(500).json({ error: "Failed to like album" });
+ }
+});
+
+// DELETE /discover/unlike - Unlike a track
+router.delete("/unlike", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { albumId } = req.body;
+
+ if (!albumId) {
+ return res.status(400).json({ error: "albumId required" });
+ }
+
+ const discoveryAlbum = await prisma.discoveryAlbum.findFirst({
+ where: {
+ userId,
+ rgMbid: albumId,
+ status: "LIKED",
+ },
+ });
+
+ if (!discoveryAlbum) {
+ return res.status(404).json({ error: "Album not liked" });
+ }
+
+ // Revert status back to ACTIVE
+ await prisma.discoveryAlbum.update({
+ where: { id: discoveryAlbum.id },
+ data: {
+ status: "ACTIVE",
+ likedAt: null,
+ },
+ });
+
+ // Remove OwnedAlbum record if it was from discovery_liked
+ await prisma.ownedAlbum.deleteMany({
+ where: {
+ rgMbid: albumId,
+ source: "discovery_liked",
+ },
+ });
+
+ // Revert plays back to DISCOVERY source
+ const tracks = await prisma.discoveryTrack.findMany({
+ where: { discoveryAlbumId: discoveryAlbum.id },
+ select: { trackId: true },
+ });
+
+ const trackIds = tracks
+ .map((t) => t.trackId)
+ .filter((id): id is string => id !== null);
+
+ if (trackIds.length > 0) {
+ await prisma.play.updateMany({
+ where: {
+ userId,
+ trackId: { in: trackIds },
+ source: "DISCOVERY_KEPT",
+ },
+ data: {
+ source: "DISCOVERY",
+ },
+ });
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error("Unlike discovery album error:", error);
+ res.status(500).json({ error: "Failed to unlike album" });
+ }
+});
+
+// GET /discover/config - Get user's Discover Weekly configuration
+router.get("/config", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ let config = await prisma.userDiscoverConfig.findUnique({
+ where: { userId },
+ });
+
+ // Create default config if doesn't exist
+ if (!config) {
+ config = await prisma.userDiscoverConfig.create({
+ data: {
+ userId,
+ playlistSize: 10,
+ maxRetryAttempts: 3,
+ exclusionMonths: 6,
+ downloadRatio: 1.3,
+ enabled: true,
+ },
+ });
+ }
+
+ res.json(config);
+ } catch (error) {
+ console.error("Get Discover Weekly config error:", error);
+ res.status(500).json({ error: "Failed to get configuration" });
+ }
+});
+
+// PATCH /discover/config - Update user's Discover Weekly configuration
+router.patch("/config", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { playlistSize, maxRetryAttempts, exclusionMonths, downloadRatio, enabled } = req.body;
+
+ // Validate playlist size
+ if (playlistSize !== undefined) {
+ const size = parseInt(playlistSize, 10);
+ if (isNaN(size) || size < 5 || size > 50 || size % 5 !== 0) {
+ return res.status(400).json({
+ error: "Invalid playlist size. Must be between 5-50 in increments of 5.",
+ });
+ }
+ }
+
+ // Validate max retry attempts
+ if (maxRetryAttempts !== undefined) {
+ const retries = parseInt(maxRetryAttempts, 10);
+ if (isNaN(retries) || retries < 1 || retries > 10) {
+ return res.status(400).json({
+ error: "Invalid retry attempts. Must be between 1-10.",
+ });
+ }
+ }
+
+ // Validate exclusion months
+ if (exclusionMonths !== undefined) {
+ const months = parseInt(exclusionMonths, 10);
+ if (isNaN(months) || months < 0 || months > 12) {
+ return res.status(400).json({
+ error: "Invalid exclusion months. Must be between 0-12.",
+ });
+ }
+ }
+
+ // Validate download ratio
+ if (downloadRatio !== undefined) {
+ const ratio = parseFloat(downloadRatio);
+ if (isNaN(ratio) || ratio < 1.0 || ratio > 2.0) {
+ return res.status(400).json({
+ error: "Invalid download ratio. Must be between 1.0-2.0.",
+ });
+ }
+ }
+
+ const config = await prisma.userDiscoverConfig.upsert({
+ where: { userId },
+ create: {
+ userId,
+ playlistSize: playlistSize ?? 10,
+ maxRetryAttempts: maxRetryAttempts ?? 3,
+ exclusionMonths: exclusionMonths ?? 6,
+ downloadRatio: downloadRatio ?? 1.3,
+ enabled: enabled ?? true,
+ },
+ update: {
+ ...(playlistSize !== undefined && {
+ playlistSize: parseInt(playlistSize, 10),
+ }),
+ ...(maxRetryAttempts !== undefined && {
+ maxRetryAttempts: parseInt(maxRetryAttempts, 10),
+ }),
+ ...(exclusionMonths !== undefined && {
+ exclusionMonths: parseInt(exclusionMonths, 10),
+ }),
+ ...(downloadRatio !== undefined && {
+ downloadRatio: parseFloat(downloadRatio),
+ }),
+ ...(enabled !== undefined && { enabled }),
+ },
+ });
+
+ res.json(config);
+ } catch (error) {
+ console.error("Update Discover Weekly config error:", error);
+ res.status(500).json({ error: "Failed to update configuration" });
+ }
+});
+
+// GET /discover/popular-artists - Get popular artists from Last.fm charts
+router.get("/popular-artists", async (req, res) => {
+ try {
+ const limit = parseInt(req.query.limit as string) || 20;
+
+ const artists = await lastFmService.getTopChartArtists(limit);
+
+ res.json({ artists });
+ } catch (error: any) {
+ console.error(
+ "[Discover] Get popular artists error:",
+ error?.message || error
+ );
+ // Return empty array instead of 500 - allows homepage to still render
+ res.json({ artists: [] });
+ }
+});
+
+// DELETE /discover/clear - Clear the discovery playlist (move liked to library, delete the rest)
+router.delete("/clear", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ console.log(`\n Clearing Discover Weekly playlist for user ${userId}`);
+
+ // Get all discovery albums for this user
+ const discoveryAlbums = await prisma.discoveryAlbum.findMany({
+ where: {
+ userId,
+ status: { in: ["ACTIVE", "LIKED"] },
+ },
+ });
+
+ if (discoveryAlbums.length === 0) {
+ return res.json({
+ success: true,
+ message: "No discovery albums to clear",
+ likedMoved: 0,
+ activeDeleted: 0,
+ });
+ }
+
+ const likedAlbums = discoveryAlbums.filter((a) => a.status === "LIKED");
+ const activeAlbums = discoveryAlbums.filter(
+ (a) => a.status === "ACTIVE"
+ );
+
+ console.log(
+ ` Found ${likedAlbums.length} liked albums to move to library`
+ );
+ console.log(` Found ${activeAlbums.length} active albums to delete`);
+
+ // Get system settings for Lidarr
+ const settings = await getSystemSettings();
+
+ let likedMoved = 0;
+ let activeDeleted = 0;
+
+ // Process liked albums - move to library
+ if (likedAlbums.length > 0) {
+ console.log(`\n[LIBRARY] Moving liked albums to library...`);
+
+ for (const album of likedAlbums) {
+ try {
+ // Find the album in the database by matching artist + title
+ const dbAlbum = await prisma.album.findFirst({
+ where: {
+ title: album.albumTitle,
+ artist: { name: album.artistName },
+ },
+ include: { artist: true },
+ });
+
+ if (dbAlbum) {
+ // Update album location to LIBRARY
+ await prisma.album.update({
+ where: { id: dbAlbum.id },
+ data: { location: "LIBRARY" },
+ });
+
+ // Create OwnedAlbum record if doesn't exist
+ await prisma.ownedAlbum.upsert({
+ where: {
+ artistId_rgMbid: {
+ artistId: dbAlbum.artistId,
+ rgMbid: dbAlbum.rgMbid,
+ },
+ },
+ create: {
+ artistId: dbAlbum.artistId,
+ rgMbid: dbAlbum.rgMbid,
+ source: "discover_liked",
+ },
+ update: {}, // No update needed if exists
+ });
+
+ // If Lidarr is enabled, move the album files to main library
+ if (
+ settings.lidarrEnabled &&
+ settings.lidarrUrl &&
+ settings.lidarrApiKey &&
+ album.lidarrAlbumId
+ ) {
+
+ try {
+ // Get album details from Lidarr
+ const albumResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/album/${album.lidarrAlbumId}`,
+ {
+ headers: {
+ "X-Api-Key": settings.lidarrApiKey,
+ },
+ timeout: 10000,
+ }
+ );
+
+ const artistId = albumResponse.data.artistId;
+
+ // Get artist details
+ const artistResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/artist/${artistId}`,
+ {
+ headers: {
+ "X-Api-Key": settings.lidarrApiKey,
+ },
+ timeout: 10000,
+ }
+ );
+
+ // Update artist's root folder path to main library if in discovery
+ if (
+ artistResponse.data.path?.includes(
+ "/music/discovery"
+ )
+ ) {
+ // Move artist to main library path
+ await axios.put(
+ `${settings.lidarrUrl}/api/v1/artist/${artistId}`,
+ {
+ ...artistResponse.data,
+ path: artistResponse.data.path.replace(
+ "/music/discovery",
+ "/music"
+ ),
+ moveFiles: true,
+ },
+ {
+ headers: {
+ "X-Api-Key":
+ settings.lidarrApiKey,
+ },
+ timeout: 30000,
+ }
+ );
+ console.log(
+ ` Moved to library: ${album.artistName} - ${album.albumTitle}`
+ );
+ }
+ } catch (lidarrError: any) {
+ console.log(
+ ` Lidarr move failed for ${album.albumTitle}: ${lidarrError.message}`
+ );
+ }
+ }
+
+ likedMoved++;
+ }
+
+ // Mark as MOVED in discovery database
+ await prisma.discoveryAlbum.update({
+ where: { id: album.id },
+ data: { status: "MOVED" },
+ });
+ } catch (error: any) {
+ console.error(
+ ` ✗ Failed to move ${album.albumTitle}: ${error.message}`
+ );
+ }
+ }
+ }
+
+ // Process active (non-liked) albums - delete them
+ if (activeAlbums.length > 0) {
+ console.log(`\n[CLEANUP] Deleting non-liked albums...`);
+
+ const checkedArtistIds = new Set();
+
+ for (const album of activeAlbums) {
+ try {
+ // Remove from Lidarr if enabled
+ if (
+ settings.lidarrEnabled &&
+ settings.lidarrUrl &&
+ settings.lidarrApiKey &&
+ album.lidarrAlbumId
+ ) {
+
+ try {
+ // Get album details to find artist ID
+ let artistId: number | undefined;
+ try {
+ const albumResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/album/${album.lidarrAlbumId}`,
+ {
+ headers: {
+ "X-Api-Key": settings.lidarrApiKey,
+ },
+ timeout: 10000,
+ }
+ );
+ artistId = albumResponse.data.artistId;
+ } catch (e: any) {
+ if (e.response?.status !== 404) throw e;
+ }
+
+ // Delete album from Lidarr
+ await axios.delete(
+ `${settings.lidarrUrl}/api/v1/album/${album.lidarrAlbumId}`,
+ {
+ params: { deleteFiles: true },
+ headers: {
+ "X-Api-Key": settings.lidarrApiKey,
+ },
+ timeout: 10000,
+ }
+ );
+ console.log(
+ ` Deleted from Lidarr: ${album.albumTitle}`
+ );
+
+ // Check if artist should be removed too
+ if (artistId && !checkedArtistIds.has(artistId)) {
+ checkedArtistIds.add(artistId);
+
+ try {
+ const artistResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/artist/${artistId}`,
+ {
+ headers: {
+ "X-Api-Key":
+ settings.lidarrApiKey,
+ },
+ timeout: 10000,
+ }
+ );
+
+ const artist = artistResponse.data;
+ const artistMbid = artist.foreignArtistId;
+
+ // Check if artist has any NATIVE library content (real user library)
+ // This is more reliable than checking Album.location which can be wrong
+ const hasNativeOwnedAlbums =
+ await prisma.ownedAlbum.findFirst({
+ where: {
+ artist: { mbid: artistMbid },
+ source: "native_scan",
+ },
+ });
+
+ // Check if artist has any LIKED/MOVED discovery albums
+ const hasKeptDiscoveryAlbums =
+ await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistMbid: artistMbid,
+ status: {
+ in: ["LIKED", "MOVED"],
+ },
+ },
+ });
+
+ // Only remove artist if they have no native library content and no kept discovery albums
+ if (
+ !hasNativeOwnedAlbums &&
+ !hasKeptDiscoveryAlbums
+ ) {
+ await axios.delete(
+ `${settings.lidarrUrl}/api/v1/artist/${artistId}`,
+ {
+ params: { deleteFiles: true },
+ headers: {
+ "X-Api-Key":
+ settings.lidarrApiKey,
+ },
+ timeout: 10000,
+ }
+ );
+ console.log(
+ ` Removed artist from Lidarr: ${artist.artistName}`
+ );
+ } else {
+ console.log(
+ ` Keeping artist in Lidarr: ${artist.artistName} (has library or kept albums)`
+ );
+ }
+ } catch (e: any) {
+ // Artist might have other albums
+ }
+ }
+ } catch (lidarrError: any) {
+ if (lidarrError.response?.status !== 404) {
+ console.log(
+ ` Lidarr delete failed for ${album.albumTitle}: ${lidarrError.message}`
+ );
+ }
+ }
+ }
+
+ // FALLBACK: Direct filesystem deletion (in case Lidarr's deleteFiles didn't work)
+ // Try to delete files directly from the discovery folder
+ try {
+ const discoveryPath = path.join(config.music.musicPath, "discovery");
+ // Try common folder structures: /discovery/Artist/Album or /discovery/Artist - Album
+ const possiblePaths = [
+ path.join(discoveryPath, album.artistName, album.albumTitle),
+ path.join(discoveryPath, album.artistName),
+ path.join(discoveryPath, `${album.artistName} - ${album.albumTitle}`),
+ ];
+
+ for (const albumPath of possiblePaths) {
+ if (fs.existsSync(albumPath)) {
+ fs.rmSync(albumPath, { recursive: true, force: true });
+ console.log(` Direct deleted: ${albumPath}`);
+ break; // Stop after first successful delete
+ }
+ }
+ } catch (fsError: any) {
+ console.log(` Filesystem delete failed for ${album.albumTitle}: ${fsError.message}`);
+ }
+
+ // Delete DiscoveryTrack records first (foreign key to Track)
+ await prisma.discoveryTrack.deleteMany({
+ where: { discoveryAlbumId: album.id },
+ });
+
+ // Remove from local database
+ const dbAlbum = await prisma.album.findFirst({
+ where: {
+ title: album.albumTitle,
+ artist: { name: album.artistName },
+ location: "DISCOVER",
+ },
+ include: { tracks: true },
+ });
+
+ if (dbAlbum) {
+ // Delete tracks first
+ await prisma.track.deleteMany({
+ where: { albumId: dbAlbum.id },
+ });
+
+ // Delete album
+ await prisma.album.delete({
+ where: { id: dbAlbum.id },
+ });
+ }
+
+ // Mark as DELETED in discovery database
+ await prisma.discoveryAlbum.update({
+ where: { id: album.id },
+ data: { status: "DELETED" },
+ });
+
+ activeDeleted++;
+ } catch (error: any) {
+ console.error(
+ ` ✗ Failed to delete ${album.albumTitle}: ${error.message}`
+ );
+ }
+ }
+ }
+
+ // ALSO clean up "extra" downloaded albums that didn't make the final playlist
+ // These are in DownloadJob but not in DiscoveryAlbum
+ // IMPORTANT: Skip any albums where the artist has LIKED content (even if MBID doesn't match)
+ if (settings.lidarrEnabled && settings.lidarrUrl && settings.lidarrApiKey) {
+ const completedJobs = await prisma.downloadJob.findMany({
+ where: {
+ userId,
+ discoveryBatchId: { not: null },
+ status: "completed",
+ },
+ });
+
+ // Get all DiscoveryAlbum for this user (including ones we just processed)
+ const allDiscoveryAlbums = await prisma.discoveryAlbum.findMany({
+ where: { userId },
+ select: { rgMbid: true, artistName: true, albumTitle: true, status: true },
+ });
+ const discoveryMbids = new Set(allDiscoveryAlbums.map(da => da.rgMbid));
+
+ // Build a set of liked artist names (case-insensitive) for extra protection
+ const likedArtistNames = new Set(
+ allDiscoveryAlbums
+ .filter(da => da.status === "LIKED" || da.status === "MOVED")
+ .map(da => da.artistName.toLowerCase())
+ );
+
+ // Find completed jobs that didn't make the playlist AND aren't from liked artists
+ const extraJobs = completedJobs.filter(job => {
+ // If MBID matches a discovery album, not an "extra"
+ if (discoveryMbids.has(job.targetMbid)) return false;
+
+ // If this job's artist has any LIKED albums, don't clean it up
+ const metadata = job.metadata as any;
+ const artistName = metadata?.artistName?.toLowerCase();
+ if (artistName && likedArtistNames.has(artistName)) {
+ console.log(` Skipping ${metadata?.albumTitle} - artist ${metadata?.artistName} has liked albums`);
+ return false;
+ }
+
+ return true;
+ });
+
+ if (extraJobs.length > 0) {
+ console.log(`\n[CLEANUP] Found ${extraJobs.length} extra albums to clean from Lidarr...`);
+
+ for (const job of extraJobs) {
+ const metadata = job.metadata as any;
+ const albumTitle = metadata?.albumTitle || job.subject;
+ const artistName = metadata?.artistName;
+
+ // Double-check: also check by artist name + album title for LIKED status
+ const isLikedByName = await prisma.discoveryAlbum.findFirst({
+ where: {
+ userId,
+ artistName: { equals: artistName, mode: "insensitive" },
+ albumTitle: { equals: albumTitle, mode: "insensitive" },
+ status: { in: ["LIKED", "MOVED"] },
+ },
+ });
+
+ if (isLikedByName) {
+ console.log(` Skipping ${albumTitle} - marked as LIKED`);
+ continue;
+ }
+
+ if (job.lidarrAlbumId) {
+ try {
+ // Get artist ID before deleting album
+ let artistId: number | undefined;
+ try {
+ const albumResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/album/${job.lidarrAlbumId}`,
+ {
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 10000,
+ }
+ );
+ artistId = albumResponse.data.artistId;
+ } catch (e) {
+ // Album might not exist
+ }
+
+ // Delete album from Lidarr
+ await axios.delete(
+ `${settings.lidarrUrl}/api/v1/album/${job.lidarrAlbumId}`,
+ {
+ params: { deleteFiles: true },
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 10000,
+ }
+ );
+ console.log(` Cleaned up extra album: ${albumTitle}`);
+
+ // Check if artist should be removed too
+ if (artistId) {
+ // Check if artist has any liked albums by NAME (more reliable than MBID)
+ const hasLikedByArtistName = await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistName: { equals: artistName, mode: "insensitive" },
+ status: { in: ["LIKED", "MOVED"] },
+ },
+ });
+
+ if (hasLikedByArtistName) {
+ console.log(` Keeping artist: ${artistName} (has liked albums)`);
+ continue;
+ }
+
+ const artistMbid = metadata?.artistMbid;
+ if (artistMbid && !artistMbid.startsWith("temp-")) {
+ // Check if artist has native library content
+ const hasNativeLibrary = await prisma.ownedAlbum.findFirst({
+ where: {
+ artist: { mbid: artistMbid },
+ source: "native_scan",
+ },
+ });
+
+ if (!hasNativeLibrary) {
+ try {
+ await axios.delete(
+ `${settings.lidarrUrl}/api/v1/artist/${artistId}`,
+ {
+ params: { deleteFiles: true },
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 10000,
+ }
+ );
+ console.log(` Removed extra artist from Lidarr: ${artistName}`);
+ } catch (e) {
+ // Artist might have other albums
+ }
+ }
+ }
+ }
+ } catch (e: any) {
+ // Ignore - might already be removed
+ if (e.response?.status !== 404) {
+ console.log(` Failed to clean up ${albumTitle}: ${e.message}`);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Clean up unavailable albums for this user
+ await prisma.unavailableAlbum.deleteMany({
+ where: { userId },
+ });
+
+ // === PHASE 1.5: Clean up failed artists from Lidarr ===
+ // Get all failed download jobs for this user and remove their artists from Lidarr
+ if (settings.lidarrEnabled && settings.lidarrUrl && settings.lidarrApiKey) {
+ console.log(`\n[CLEANUP] Checking for failed artists to remove from Lidarr...`);
+
+ const failedJobs = await prisma.downloadJob.findMany({
+ where: {
+ userId,
+ status: "failed",
+ discoveryBatchId: { not: null },
+ },
+ });
+
+ // Group by artist
+ const failedArtistMbids = new Set();
+ const artistNames = new Map();
+
+ for (const job of failedJobs) {
+ const metadata = job.metadata as any;
+ if (metadata?.artistMbid) {
+ failedArtistMbids.add(metadata.artistMbid);
+ artistNames.set(metadata.artistMbid, metadata.artistName || "Unknown");
+ }
+ }
+
+ // Remove failed artists that don't have native library content
+ for (const artistMbid of failedArtistMbids) {
+ try {
+ // Check if artist has any NATIVE library content (real user library)
+ const hasNativeOwnedAlbums = await prisma.ownedAlbum.findFirst({
+ where: {
+ artist: { mbid: artistMbid },
+ source: "native_scan",
+ },
+ });
+
+ if (hasNativeOwnedAlbums) {
+ console.log(` Keeping ${artistNames.get(artistMbid)} - has native library content`);
+ continue;
+ }
+
+ // Check if artist has any LIKED discovery albums
+ const hasLikedDiscovery = await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistMbid,
+ status: { in: ["LIKED", "MOVED"] },
+ },
+ });
+
+ if (hasLikedDiscovery) {
+ console.log(` Keeping ${artistNames.get(artistMbid)} - has liked discovery albums`);
+ continue;
+ }
+
+ // Find and remove from Lidarr
+ const searchResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/artist`,
+ {
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 10000,
+ }
+ );
+
+ const lidarrArtist = searchResponse.data.find(
+ (a: any) => a.foreignArtistId === artistMbid
+ );
+
+ if (lidarrArtist) {
+ await axios.delete(
+ `${settings.lidarrUrl}/api/v1/artist/${lidarrArtist.id}`,
+ {
+ params: { deleteFiles: true },
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 10000,
+ }
+ );
+ console.log(` ✓ Removed failed artist from Lidarr: ${artistNames.get(artistMbid)}`);
+ }
+ } catch (e: any) {
+ // Ignore errors - artist might already be removed
+ }
+ }
+
+ // DON'T delete download jobs immediately - scanner needs them to identify discovery albums
+ // They will be cleaned up by the data integrity worker after 30 days
+ // Only delete FAILED jobs (they won't help with matching anyway)
+ await prisma.downloadJob.deleteMany({
+ where: {
+ userId,
+ discoveryBatchId: { not: null },
+ status: "failed",
+ },
+ });
+ }
+
+ // === PHASE 2: Clean up orphaned discovery records ===
+ // These are Album/Track records with location="DISCOVER" that weren't linked to a DiscoveryAlbum
+ // This can happen if downloads failed or playlist build failed
+ console.log(`\n Cleaning up orphaned discovery records...`);
+
+ // Find all DISCOVER albums that don't have a corresponding DiscoveryAlbum record
+ const orphanedAlbums = await prisma.album.findMany({
+ where: {
+ location: "DISCOVER",
+ },
+ include: { artist: true, tracks: true },
+ });
+
+ let orphanedAlbumsDeleted = 0;
+ for (const orphanAlbum of orphanedAlbums) {
+ // Check if there's a DiscoveryAlbum record for this
+ // Include MOVED status because liked albums are marked MOVED during clear
+ const hasDiscoveryRecord = await prisma.discoveryAlbum.findFirst({
+ where: {
+ OR: [
+ { rgMbid: orphanAlbum.rgMbid },
+ {
+ albumTitle: orphanAlbum.title,
+ artistName: orphanAlbum.artist.name,
+ },
+ ],
+ status: { in: ["ACTIVE", "LIKED", "MOVED"] }, // Keep if active, liked, or moved to library
+ },
+ });
+
+ // Also check if there's an OwnedAlbum record (user liked it)
+ const hasOwnedRecord = await prisma.ownedAlbum.findFirst({
+ where: {
+ rgMbid: orphanAlbum.rgMbid,
+ },
+ });
+
+ if (!hasDiscoveryRecord && !hasOwnedRecord) {
+ // Delete tracks first
+ await prisma.track.deleteMany({
+ where: { albumId: orphanAlbum.id },
+ });
+ // Delete album
+ await prisma.album.delete({
+ where: { id: orphanAlbum.id },
+ });
+ orphanedAlbumsDeleted++;
+ console.log(
+ ` Deleted orphaned album: ${orphanAlbum.artist.name} - ${orphanAlbum.title}`
+ );
+ }
+ }
+
+ if (orphanedAlbumsDeleted > 0) {
+ console.log(
+ ` Cleaned up ${orphanedAlbumsDeleted} orphaned discovery albums`
+ );
+ }
+
+ // Clean up orphaned artists (artists with no albums)
+ const orphanedArtists = await prisma.artist.findMany({
+ where: {
+ albums: { none: {} },
+ },
+ });
+
+ if (orphanedArtists.length > 0) {
+ const orphanIds = orphanedArtists.map((a) => a.id);
+
+ // Delete artist relations first (SimilarArtist records)
+ // Note: SimilarArtist uses fromArtistId/toArtistId field names
+ await prisma.similarArtist.deleteMany({
+ where: {
+ OR: [
+ { fromArtistId: { in: orphanIds } },
+ { toArtistId: { in: orphanIds } },
+ ],
+ },
+ });
+
+ await prisma.artist.deleteMany({
+ where: { id: { in: orphanIds } },
+ });
+ console.log(
+ ` Cleaned up ${orphanedArtists.length} orphaned artists`
+ );
+ }
+
+ // Clean up orphaned DiscoveryTrack records (tracks whose album was deleted)
+ const orphanedDiscoveryTracks = await prisma.discoveryTrack.deleteMany({
+ where: {
+ trackId: null, // Track was deleted but DiscoveryTrack record remains
+ },
+ });
+
+ if (orphanedDiscoveryTracks.count > 0) {
+ console.log(
+ ` Cleaned up ${orphanedDiscoveryTracks.count} orphaned discovery track records`
+ );
+ }
+
+ // Clean up old DiscoveryAlbum records that are DELETED or MOVED (older than 30 days)
+ const thirtyDaysAgo = new Date();
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
+
+ const oldDiscoveryAlbums = await prisma.discoveryAlbum.deleteMany({
+ where: {
+ userId,
+ status: { in: ["DELETED", "MOVED"] },
+ downloadedAt: { lt: thirtyDaysAgo },
+ },
+ });
+
+ if (oldDiscoveryAlbums.count > 0) {
+ console.log(
+ ` Cleaned up ${oldDiscoveryAlbums.count} old discovery album records`
+ );
+ }
+
+ // === PHASE 3: Tag-based Lidarr cleanup ===
+ // Only remove artists that have the "lidify-discovery" tag
+ // This is the ONLY reliable way to identify discovery artists
+ // User's pre-existing library is NEVER touched (no tag = safe)
+ let lidarrArtistsRemoved = 0;
+ if (settings.lidarrEnabled && settings.lidarrUrl && settings.lidarrApiKey) {
+ console.log(`\n[LIDARR CLEANUP] Tag-based cleanup (lidify-discovery tag)...`);
+
+ try {
+ // Get all artists with the discovery tag
+ const discoveryArtists = await lidarrService.getDiscoveryArtists();
+ console.log(` Found ${discoveryArtists.length} artists with discovery tag`);
+
+ for (const lidarrArtist of discoveryArtists) {
+ const artistMbid = lidarrArtist.foreignArtistId;
+ const artistName = lidarrArtist.artistName;
+
+ if (!artistMbid) continue;
+
+ // Double-check: if artist has LIKED albums, remove tag but don't delete
+ // (This is a safety net - the like endpoint should have already removed the tag)
+ const hasKeptDiscovery = await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistMbid: artistMbid,
+ status: { in: ["LIKED", "MOVED"] },
+ },
+ });
+
+ if (hasKeptDiscovery) {
+ // Remove the tag but keep the artist
+ console.log(` Keeping ${artistName} - has liked albums (removing tag)`);
+ await lidarrService.removeDiscoveryTagByMbid(artistMbid);
+ continue;
+ }
+
+ // Artist has discovery tag AND no liked albums = safe to delete
+ try {
+ const result = await lidarrService.deleteArtistById(lidarrArtist.id, true);
+ if (result.success) {
+ lidarrArtistsRemoved++;
+ console.log(` ✓ Removed: ${artistName}`);
+ }
+ } catch (deleteError: any) {
+ console.log(` ✗ Failed to remove ${artistName}: ${deleteError.message}`);
+ }
+ }
+
+ console.log(` Tag-based cleanup complete: ${lidarrArtistsRemoved} artists removed`);
+ } catch (lidarrError: any) {
+ console.log(` Lidarr cleanup failed: ${lidarrError.message}`);
+ }
+ }
+
+ // === PHASE 4: Trigger library scan to sync database with filesystem ===
+ console.log(`\n[SCAN] Triggering library scan to sync database...`);
+ try {
+ await scanQueue.add("scan", {
+ userId,
+ musicPath: config.music.musicPath,
+ });
+ console.log(` Library scan queued successfully`);
+ } catch (scanError: any) {
+ console.log(` Library scan queue failed: ${scanError.message}`);
+ // Non-fatal - continue with response
+ }
+
+ console.log(
+ `\nClear complete: ${likedMoved} moved to library, ${activeDeleted} deleted, ${orphanedAlbumsDeleted} orphans cleaned, ${lidarrArtistsRemoved} Lidarr artists removed`
+ );
+
+ res.json({
+ success: true,
+ message: "Discovery playlist cleared",
+ likedMoved,
+ activeDeleted,
+ orphanedAlbumsDeleted,
+ lidarrArtistsRemoved,
+ });
+ } catch (error: any) {
+ console.error("Clear discovery playlist error:", error?.message || error);
+ console.error("Stack:", error?.stack);
+ res.status(500).json({
+ error: "Failed to clear discovery playlist",
+ details: error?.message || "Unknown error"
+ });
+ }
+});
+
+// GET /discover/exclusions - Get all exclusions for current user
+router.get("/exclusions", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ const exclusions = await prisma.discoverExclusion.findMany({
+ where: {
+ userId,
+ expiresAt: { gt: new Date() } // Only active exclusions
+ },
+ orderBy: { lastSuggestedAt: "desc" },
+ });
+
+ // Return exclusions with names
+ const mapped = exclusions.map((exc) => ({
+ id: exc.id,
+ albumMbid: exc.albumMbid,
+ artistName: exc.artistName || "Unknown Artist",
+ albumTitle: exc.albumTitle || exc.albumMbid.slice(0, 8) + "...",
+ lastSuggestedAt: exc.lastSuggestedAt,
+ expiresAt: exc.expiresAt,
+ }));
+
+ res.json({
+ exclusions: mapped,
+ count: exclusions.length,
+ });
+ } catch (error: any) {
+ console.error("Get exclusions error:", error?.message || error);
+ console.error("Stack:", error?.stack);
+ res.status(500).json({ error: "Failed to get exclusions", details: error?.message });
+ }
+});
+
+// DELETE /discover/exclusions - Clear all exclusions for current user
+router.delete("/exclusions", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ const result = await prisma.discoverExclusion.deleteMany({
+ where: { userId },
+ });
+
+ console.log(`[Discovery] Cleared ${result.count} exclusions for user ${userId}`);
+
+ res.json({
+ success: true,
+ message: `Cleared ${result.count} exclusions`,
+ clearedCount: result.count,
+ });
+ } catch (error) {
+ console.error("Clear exclusions error:", error);
+ res.status(500).json({ error: "Failed to clear exclusions" });
+ }
+});
+
+// DELETE /discover/exclusions/:id - Remove a specific exclusion
+router.delete("/exclusions/:id", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { id } = req.params;
+
+ const exclusion = await prisma.discoverExclusion.findFirst({
+ where: { id, userId },
+ });
+
+ if (!exclusion) {
+ return res.status(404).json({ error: "Exclusion not found" });
+ }
+
+ await prisma.discoverExclusion.delete({
+ where: { id },
+ });
+
+ res.json({
+ success: true,
+ message: "Exclusion removed",
+ });
+ } catch (error) {
+ console.error("Remove exclusion error:", error);
+ res.status(500).json({ error: "Failed to remove exclusion" });
+ }
+});
+
+// POST /discover/cleanup-lidarr - Remove discovery-only artists from Lidarr
+// This cleans up artists that were added for discovery but shouldn't remain
+router.post("/cleanup-lidarr", async (req, res) => {
+ try {
+ console.log("\n[CLEANUP] Starting Lidarr cleanup of discovery-only artists...");
+
+ const settings = await getSystemSettings();
+
+ if (!settings.lidarrEnabled || !settings.lidarrUrl || !settings.lidarrApiKey) {
+ return res.status(400).json({ error: "Lidarr not configured" });
+ }
+
+ // Get all artists from Lidarr
+ const lidarrResponse = await axios.get(
+ `${settings.lidarrUrl}/api/v1/artist`,
+ {
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 30000,
+ }
+ );
+
+ const lidarrArtists = lidarrResponse.data;
+ console.log(`[CLEANUP] Found ${lidarrArtists.length} artists in Lidarr`);
+
+ const artistsRemoved: string[] = [];
+ const artistsKept: string[] = [];
+ const errors: string[] = [];
+
+ for (const lidarrArtist of lidarrArtists) {
+ const artistMbid = lidarrArtist.foreignArtistId;
+ const artistName = lidarrArtist.artistName;
+
+ if (!artistMbid) continue;
+
+ try {
+ // Check if this artist has any NATIVE library content (real user library)
+ // This is more reliable than checking Album.location which can be wrong
+ const hasNativeOwnedAlbums = await prisma.ownedAlbum.findFirst({
+ where: {
+ artist: { mbid: artistMbid },
+ source: "native_scan",
+ },
+ });
+
+ // Check if artist has any LIKED/MOVED discovery albums
+ const hasKeptDiscoveryAlbums = await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistMbid: artistMbid,
+ status: { in: ["LIKED", "MOVED"] },
+ },
+ });
+
+ // Check if artist has any ACTIVE discovery albums (current playlist)
+ const hasActiveDiscoveryAlbums = await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistMbid: artistMbid,
+ status: "ACTIVE",
+ },
+ });
+
+ if (hasNativeOwnedAlbums || hasKeptDiscoveryAlbums) {
+ // This artist should stay in Lidarr
+ artistsKept.push(`${artistName} (has native library or kept albums)`);
+ continue;
+ }
+
+ if (hasActiveDiscoveryAlbums) {
+ // This artist has a current discovery album, keep for now
+ artistsKept.push(`${artistName} (has active discovery)`);
+ continue;
+ }
+
+ // This artist has no library albums and no active/kept discovery albums
+ // They should be removed from Lidarr
+ console.log(`[CLEANUP] Removing discovery-only artist: ${artistName}`);
+
+ await axios.delete(
+ `${settings.lidarrUrl}/api/v1/artist/${lidarrArtist.id}`,
+ {
+ params: { deleteFiles: true },
+ headers: { "X-Api-Key": settings.lidarrApiKey },
+ timeout: 30000,
+ }
+ );
+
+ artistsRemoved.push(artistName);
+ console.log(`[CLEANUP] ✓ Removed: ${artistName}`);
+
+ } catch (error: any) {
+ const msg = `Failed to process ${artistName}: ${error.message}`;
+ errors.push(msg);
+ console.error(`[CLEANUP] ✗ ${msg}`);
+ }
+ }
+
+ console.log(`\n[CLEANUP] Complete:`);
+ console.log(` - Removed: ${artistsRemoved.length}`);
+ console.log(` - Kept: ${artistsKept.length}`);
+ console.log(` - Errors: ${errors.length}`);
+
+ res.json({
+ success: true,
+ removed: artistsRemoved,
+ kept: artistsKept,
+ errors,
+ summary: {
+ removed: artistsRemoved.length,
+ kept: artistsKept.length,
+ errors: errors.length,
+ },
+ });
+ } catch (error: any) {
+ console.error("[CLEANUP] Lidarr cleanup error:", error?.message || error);
+ res.status(500).json({
+ error: "Failed to cleanup Lidarr",
+ details: error?.message || "Unknown error",
+ });
+ }
+});
+
+// POST /discover/fix-tagging - Fix albums incorrectly tagged as LIBRARY that should be DISCOVER
+// This repairs existing bad data caused by scanner timing issues
+// IMPORTANT: Does NOT touch albums that user has LIKED (discovery_liked) or native library
+router.post("/fix-tagging", async (req, res) => {
+ try {
+ console.log("\n[FIX-TAGGING] Starting album tagging repair...");
+
+ // Get all discovery artists (from DiscoveryAlbum records)
+ const discoveryArtists = await prisma.discoveryAlbum.findMany({
+ distinct: ['artistMbid'],
+ select: { artistMbid: true, artistName: true },
+ });
+
+ console.log(`[FIX-TAGGING] Found ${discoveryArtists.length} artists with discovery records`);
+
+ let albumsFixed = 0;
+ let ownedRecordsRemoved = 0;
+ const fixedArtists: string[] = [];
+
+ for (const da of discoveryArtists) {
+ if (!da.artistMbid) continue;
+
+ // Check if artist has ANY protected content:
+ // 1. native_scan = real user library from before discovery
+ // 2. discovery_liked = user liked a discovery album (should be kept!)
+ const hasProtectedContent = await prisma.ownedAlbum.findFirst({
+ where: {
+ artist: { mbid: da.artistMbid },
+ source: { in: ["native_scan", "discovery_liked"] },
+ },
+ });
+
+ if (hasProtectedContent) {
+ // Artist has protected content - don't touch their albums
+ console.log(`[FIX-TAGGING] Skipping ${da.artistName} - has protected content (${hasProtectedContent.source})`);
+ continue;
+ }
+
+ // Also check if artist has any LIKED discovery albums (double-check)
+ const hasLikedDiscovery = await prisma.discoveryAlbum.findFirst({
+ where: {
+ artistMbid: da.artistMbid,
+ status: { in: ["LIKED", "MOVED"] },
+ },
+ });
+
+ if (hasLikedDiscovery) {
+ // User liked albums from this artist - don't touch
+ console.log(`[FIX-TAGGING] Skipping ${da.artistName} - has LIKED discovery albums`);
+ continue;
+ }
+
+ // This artist has NO protected content - they're purely an ACTIVE discovery artist
+ // Fix any of their albums that are incorrectly tagged as LIBRARY
+ const mistaggedAlbums = await prisma.album.findMany({
+ where: {
+ artist: { mbid: da.artistMbid },
+ location: "LIBRARY",
+ },
+ });
+
+ if (mistaggedAlbums.length > 0) {
+ // Update all these albums to DISCOVER
+ const updated = await prisma.album.updateMany({
+ where: {
+ artist: { mbid: da.artistMbid },
+ location: "LIBRARY",
+ },
+ data: { location: "DISCOVER" },
+ });
+
+ // Remove incorrect OwnedAlbum records (but not protected ones)
+ const removed = await prisma.ownedAlbum.deleteMany({
+ where: {
+ artist: { mbid: da.artistMbid },
+ source: { notIn: ["native_scan", "discovery_liked"] },
+ },
+ });
+
+ albumsFixed += updated.count;
+ ownedRecordsRemoved += removed.count;
+ fixedArtists.push(da.artistName);
+
+ console.log(`[FIX-TAGGING] Fixed ${updated.count} albums for ${da.artistName}`);
+ }
+ }
+
+ console.log(`[FIX-TAGGING] Complete: ${albumsFixed} albums fixed, ${ownedRecordsRemoved} OwnedAlbum records removed`);
+
+ res.json({
+ success: true,
+ albumsFixed,
+ ownedRecordsRemoved,
+ fixedArtists,
+ });
+ } catch (error: any) {
+ console.error("[FIX-TAGGING] Error:", error?.message || error);
+ res.status(500).json({
+ error: "Failed to fix album tagging",
+ details: error?.message || "Unknown error",
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts
new file mode 100644
index 0000000..899aaca
--- /dev/null
+++ b/backend/src/routes/downloads.ts
@@ -0,0 +1,588 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { config } from "../config";
+import { lidarrService } from "../services/lidarr";
+import { musicBrainzService } from "../services/musicbrainz";
+import { simpleDownloadManager } from "../services/simpleDownloadManager";
+import crypto from "crypto";
+
+const router = Router();
+
+router.use(requireAuthOrToken);
+
+// POST /downloads - Create download job
+router.post("/", async (req, res) => {
+ try {
+ const {
+ type,
+ mbid,
+ subject,
+ artistName,
+ albumTitle,
+ downloadType = "library",
+ } = req.body;
+ const userId = req.user!.id;
+
+ if (!type || !mbid || !subject) {
+ return res.status(400).json({
+ error: "Missing required fields: type, mbid, subject",
+ });
+ }
+
+ if (type !== "artist" && type !== "album") {
+ return res
+ .status(400)
+ .json({ error: "Type must be 'artist' or 'album'" });
+ }
+
+ if (downloadType !== "library" && downloadType !== "discovery") {
+ return res.status(400).json({
+ error: "downloadType must be 'library' or 'discovery'",
+ });
+ }
+
+ // Check if Lidarr is enabled (database or .env)
+ const lidarrEnabled = await lidarrService.isEnabled();
+ if (!lidarrEnabled) {
+ return res.status(400).json({
+ error: "Lidarr not configured. Please add albums manually to your library.",
+ });
+ }
+
+ // Determine root folder path based on download type
+ const rootFolderPath =
+ downloadType === "discovery" ? "/music/discovery" : "/music";
+
+ if (type === "artist") {
+ // For artist downloads, fetch albums and create individual jobs
+ const jobs = await processArtistDownload(
+ userId,
+ mbid,
+ subject,
+ rootFolderPath,
+ downloadType
+ );
+
+ return res.json({
+ id: jobs[0]?.id || null,
+ status: "processing",
+ downloadType,
+ rootFolderPath,
+ message: `Creating download jobs for ${jobs.length} album(s)...`,
+ albumCount: jobs.length,
+ jobs: jobs.map((j) => ({ id: j.id, subject: j.subject })),
+ });
+ }
+
+ // Single album download - check for existing job first
+ const existingJob = await prisma.downloadJob.findFirst({
+ where: {
+ targetMbid: mbid,
+ status: { in: ["pending", "processing"] },
+ },
+ });
+
+ if (existingJob) {
+ console.log(`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`);
+ return res.json({
+ id: existingJob.id,
+ status: existingJob.status,
+ downloadType,
+ rootFolderPath,
+ message: "Download already in progress",
+ duplicate: true,
+ });
+ }
+
+ const job = await prisma.downloadJob.create({
+ data: {
+ userId,
+ subject,
+ type,
+ targetMbid: mbid,
+ status: "pending",
+ metadata: {
+ downloadType,
+ rootFolderPath,
+ artistName,
+ albumTitle,
+ },
+ },
+ });
+
+ console.log(
+ `[DOWNLOAD] Triggering Lidarr: ${type} "${subject}" -> ${rootFolderPath}`
+ );
+
+ // Process in background
+ processDownload(
+ job.id,
+ type,
+ mbid,
+ subject,
+ rootFolderPath,
+ artistName,
+ albumTitle
+ ).catch((error) => {
+ console.error(
+ `Download processing failed for job ${job.id}:`,
+ error
+ );
+ });
+
+ res.json({
+ id: job.id,
+ status: job.status,
+ downloadType,
+ rootFolderPath,
+ message: "Download job created. Processing in background.",
+ });
+ } catch (error) {
+ console.error("Create download job error:", error);
+ res.status(500).json({ error: "Failed to create download job" });
+ }
+});
+
+/**
+ * Process artist download by creating individual album jobs
+ */
+async function processArtistDownload(
+ userId: string,
+ artistMbid: string,
+ artistName: string,
+ rootFolderPath: string,
+ downloadType: string
+): Promise<{ id: string; subject: string }[]> {
+ console.log(`\n Processing artist download: ${artistName}`);
+ console.log(` Artist MBID: ${artistMbid}`);
+
+ // Generate a batch ID to group all album downloads
+ const batchId = crypto.randomUUID();
+ console.log(` Batch ID: ${batchId}`);
+
+ try {
+ // First, add the artist to Lidarr (this monitors all albums)
+ const lidarrArtist = await lidarrService.addArtist(
+ artistMbid,
+ artistName,
+ rootFolderPath
+ );
+
+ if (!lidarrArtist) {
+ console.log(` Failed to add artist to Lidarr`);
+ throw new Error("Failed to add artist to Lidarr");
+ }
+
+ console.log(` Artist added to Lidarr (ID: ${lidarrArtist.id})`);
+
+ // Fetch albums from MusicBrainz
+ const releaseGroups = await musicBrainzService.getReleaseGroups(
+ artistMbid,
+ ["album", "ep"],
+ 100
+ );
+
+ console.log(
+ ` Found ${releaseGroups.length} albums/EPs from MusicBrainz`
+ );
+
+ if (releaseGroups.length === 0) {
+ console.log(` No albums found for artist`);
+ return [];
+ }
+
+ // Create individual album jobs
+ const jobs: { id: string; subject: string }[] = [];
+
+ for (const rg of releaseGroups) {
+ const albumMbid = rg.id;
+ const albumTitle = rg.title;
+ const albumSubject = `${artistName} - ${albumTitle}`;
+
+ // Check if we already have this album downloaded
+ const existingAlbum = await prisma.album.findFirst({
+ where: { rgMbid: albumMbid },
+ });
+
+ if (existingAlbum) {
+ console.log(` Skipping "${albumTitle}" - already in library`);
+ continue;
+ }
+
+ // Check if there's already a pending/processing job for this album
+ const existingJob = await prisma.downloadJob.findFirst({
+ where: {
+ targetMbid: albumMbid,
+ status: { in: ["pending", "processing"] },
+ },
+ });
+
+ if (existingJob) {
+ console.log(
+ ` Skipping "${albumTitle}" - already in download queue`
+ );
+ continue;
+ }
+
+ // Create download job for this album
+ const now = new Date();
+ const job = await prisma.downloadJob.create({
+ data: {
+ userId,
+ subject: albumSubject,
+ type: "album",
+ targetMbid: albumMbid,
+ status: "pending",
+ metadata: {
+ downloadType,
+ rootFolderPath,
+ artistName,
+ artistMbid,
+ albumTitle,
+ batchId, // Link all albums in this artist download
+ batchArtist: artistName,
+ createdAt: now.toISOString(), // Track when job was created for timeout
+ },
+ },
+ });
+
+ jobs.push({ id: job.id, subject: albumSubject });
+ console.log(` [JOB] Created job for: ${albumSubject}`);
+
+ // Start the download in background
+ processDownload(
+ job.id,
+ "album",
+ albumMbid,
+ albumSubject,
+ rootFolderPath,
+ artistName,
+ albumTitle
+ ).catch((error) => {
+ console.error(`Download failed for ${albumSubject}:`, error);
+ });
+ }
+
+ console.log(` Created ${jobs.length} album download jobs`);
+ return jobs;
+ } catch (error: any) {
+ console.error(` Failed to process artist download:`, error.message);
+ throw error;
+ }
+}
+
+// Background download processor
+async function processDownload(
+ jobId: string,
+ type: string,
+ mbid: string,
+ subject: string,
+ rootFolderPath: string,
+ artistName?: string,
+ albumTitle?: string
+) {
+ const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
+ if (!job) {
+ console.error(`Job ${jobId} not found`);
+ return;
+ }
+
+ if (type === "album") {
+ // For albums, use the simple download manager
+ let parsedArtist = artistName;
+ let parsedAlbum = albumTitle;
+
+ if (!parsedArtist || !parsedAlbum) {
+ const parts = subject.split(" - ");
+ if (parts.length >= 2) {
+ parsedArtist = parts[0].trim();
+ parsedAlbum = parts.slice(1).join(" - ").trim();
+ } else {
+ parsedArtist = subject;
+ parsedAlbum = subject;
+ }
+ }
+
+ console.log(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`);
+
+ // Use simple download manager for album downloads
+ const result = await simpleDownloadManager.startDownload(
+ jobId,
+ parsedArtist,
+ parsedAlbum,
+ mbid,
+ job.userId
+ );
+
+ if (!result.success) {
+ console.error(`Failed to start download: ${result.error}`);
+ }
+ }
+}
+
+// DELETE /downloads/clear-all - Clear all download jobs for the current user
+// IMPORTANT: Must be BEFORE /:id route to avoid catching "clear-all" as an ID
+router.delete("/clear-all", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { status } = req.query;
+
+ const where: any = { userId };
+ if (status) {
+ where.status = status as string;
+ }
+
+ const result = await prisma.downloadJob.deleteMany({ where });
+
+ console.log(
+ ` Cleared ${result.count} download jobs for user ${userId}`
+ );
+ res.json({ success: true, deleted: result.count });
+ } catch (error) {
+ console.error("Clear downloads error:", error);
+ res.status(500).json({ error: "Failed to clear downloads" });
+ }
+});
+
+// POST /downloads/clear-lidarr-queue - Clear stuck/failed items from Lidarr's queue
+router.post("/clear-lidarr-queue", async (req, res) => {
+ try {
+ const result = await simpleDownloadManager.clearLidarrQueue();
+ res.json({
+ success: true,
+ removed: result.removed,
+ errors: result.errors,
+ });
+ } catch (error: any) {
+ console.error("Clear Lidarr queue error:", error);
+ res.status(500).json({ error: "Failed to clear Lidarr queue" });
+ }
+});
+
+// GET /downloads/failed - List failed/unavailable albums for the current user
+// IMPORTANT: Must be BEFORE /:id route to avoid catching "failed" as an ID
+router.get("/failed", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ const failedAlbums = await prisma.unavailableAlbum.findMany({
+ where: { userId },
+ orderBy: { createdAt: "desc" },
+ });
+
+ res.json(failedAlbums);
+ } catch (error) {
+ console.error("List failed albums error:", error);
+ res.status(500).json({ error: "Failed to list failed albums" });
+ }
+});
+
+// DELETE /downloads/failed/:id - Dismiss a failed album notification
+router.delete("/failed/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user!.id;
+
+ // Verify ownership before deleting
+ const failedAlbum = await prisma.unavailableAlbum.findFirst({
+ where: { id, userId },
+ });
+
+ if (!failedAlbum) {
+ return res.status(404).json({ error: "Failed album not found" });
+ }
+
+ await prisma.unavailableAlbum.delete({
+ where: { id },
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error("Delete failed album error:", error);
+ res.status(500).json({ error: "Failed to delete failed album" });
+ }
+});
+
+// GET /downloads/:id - Get download job status
+router.get("/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user!.id;
+
+ const job = await prisma.downloadJob.findFirst({
+ where: {
+ id,
+ userId,
+ },
+ });
+
+ if (!job) {
+ return res.status(404).json({ error: "Download job not found" });
+ }
+
+ res.json(job);
+ } catch (error) {
+ console.error("Get download job error:", error);
+ res.status(500).json({ error: "Failed to get download job" });
+ }
+});
+
+// PATCH /downloads/:id - Update download job (e.g., mark as complete)
+router.patch("/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user!.id;
+ const { status } = req.body;
+
+ const job = await prisma.downloadJob.findFirst({
+ where: {
+ id,
+ userId,
+ },
+ });
+
+ if (!job) {
+ return res.status(404).json({ error: "Download job not found" });
+ }
+
+ const updated = await prisma.downloadJob.update({
+ where: { id },
+ data: {
+ status: status || "completed",
+ completedAt: status === "completed" ? new Date() : undefined,
+ },
+ });
+
+ res.json(updated);
+ } catch (error) {
+ console.error("Update download job error:", error);
+ res.status(500).json({ error: "Failed to update download job" });
+ }
+});
+
+// DELETE /downloads/:id - Delete download job
+router.delete("/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+ const userId = req.user!.id;
+
+ // Use deleteMany to handle race conditions gracefully
+ // This won't throw an error if the record was already deleted
+ const result = await prisma.downloadJob.deleteMany({
+ where: {
+ id,
+ userId,
+ },
+ });
+
+ // Return success even if nothing was deleted (idempotent delete)
+ res.json({ success: true, deleted: result.count > 0 });
+ } catch (error: any) {
+ console.error("Delete download job error:", error);
+ console.error("Error details:", error.message, error.stack);
+ res.status(500).json({
+ error: "Failed to delete download job",
+ details: error.message,
+ });
+ }
+});
+
+// GET /downloads - List user's download jobs
+router.get("/", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { status, limit = "50", includeDiscovery = "false", includeCleared = "false" } = req.query;
+
+ const where: any = { userId };
+ if (status) {
+ where.status = status as string;
+ }
+ // Filter out cleared jobs by default (user dismissed from history)
+ if (includeCleared !== "true") {
+ where.cleared = false;
+ }
+
+ const jobs = await prisma.downloadJob.findMany({
+ where,
+ orderBy: { createdAt: "desc" },
+ take: parseInt(limit as string, 10),
+ });
+
+ // Filter out discovery downloads unless explicitly requested
+ // Discovery downloads are automated and shouldn't show in the UI popover
+ const filteredJobs =
+ includeDiscovery === "true"
+ ? jobs
+ : jobs.filter((job) => {
+ const metadata = job.metadata as any;
+ return metadata?.downloadType !== "discovery";
+ });
+
+ res.json(filteredJobs);
+ } catch (error) {
+ console.error("List download jobs error:", error);
+ res.status(500).json({ error: "Failed to list download jobs" });
+ }
+});
+
+// POST /downloads/keep-track - Keep a discovery track (move to permanent library)
+router.post("/keep-track", async (req, res) => {
+ try {
+ const { discoveryTrackId } = req.body;
+ const userId = req.user!.id;
+
+ if (!discoveryTrackId) {
+ return res.status(400).json({ error: "Missing discoveryTrackId" });
+ }
+
+ const discoveryTrack = await prisma.discoveryTrack.findUnique({
+ where: { id: discoveryTrackId },
+ include: {
+ discoveryAlbum: true,
+ },
+ });
+
+ if (!discoveryTrack) {
+ return res.status(404).json({ error: "Discovery track not found" });
+ }
+
+ // Mark as kept
+ await prisma.discoveryTrack.update({
+ where: { id: discoveryTrackId },
+ data: { userKept: true },
+ });
+
+ // If Lidarr enabled, create job to download full album to permanent library
+ const lidarrEnabled = await lidarrService.isEnabled();
+ if (lidarrEnabled) {
+ const job = await prisma.downloadJob.create({
+ data: {
+ userId,
+ subject: `${discoveryTrack.discoveryAlbum.albumTitle} by ${discoveryTrack.discoveryAlbum.artistName}`,
+ type: "album",
+ targetMbid: discoveryTrack.discoveryAlbum.rgMbid,
+ status: "pending",
+ },
+ });
+
+ return res.json({
+ success: true,
+ message:
+ "Track marked as kept. Full album will be downloaded to permanent library.",
+ downloadJobId: job.id,
+ });
+ }
+
+ res.json({
+ success: true,
+ message:
+ "Track marked as kept. Please add the full album manually to your /music folder.",
+ });
+ } catch (error) {
+ console.error("Keep track error:", error);
+ res.status(500).json({ error: "Failed to keep track" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/enrichment.ts b/backend/src/routes/enrichment.ts
new file mode 100644
index 0000000..4ec57a7
--- /dev/null
+++ b/backend/src/routes/enrichment.ts
@@ -0,0 +1,293 @@
+import { Router } from "express";
+import { requireAuth, requireAdmin } from "../middleware/auth";
+import { enrichmentService } from "../services/enrichment";
+import { getEnrichmentProgress, runFullEnrichment } from "../workers/unifiedEnrichment";
+
+const router = Router();
+
+router.use(requireAuth);
+
+/**
+ * GET /enrichment/progress
+ * Get comprehensive enrichment progress (artists, track tags, audio analysis)
+ */
+router.get("/progress", async (req, res) => {
+ try {
+ const progress = await getEnrichmentProgress();
+ res.json(progress);
+ } catch (error) {
+ console.error("Get enrichment progress error:", error);
+ res.status(500).json({ error: "Failed to get progress" });
+ }
+});
+
+/**
+ * POST /enrichment/full
+ * Trigger full enrichment (re-enriches everything regardless of status)
+ * Admin only
+ */
+router.post("/full", requireAdmin, async (req, res) => {
+ try {
+ // This runs in the background
+ runFullEnrichment().catch(err => {
+ console.error("Full enrichment error:", err);
+ });
+
+ res.json({
+ message: "Full enrichment started",
+ description: "All artists, track tags, and audio analysis will be re-processed"
+ });
+ } catch (error) {
+ console.error("Trigger full enrichment error:", error);
+ res.status(500).json({ error: "Failed to start full enrichment" });
+ }
+});
+
+/**
+ * GET /enrichment/settings
+ * Get enrichment settings for current user
+ */
+router.get("/settings", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const settings = await enrichmentService.getSettings(userId);
+ res.json(settings);
+ } catch (error) {
+ console.error("Get enrichment settings error:", error);
+ res.status(500).json({ error: "Failed to get settings" });
+ }
+});
+
+/**
+ * PUT /enrichment/settings
+ * Update enrichment settings for current user
+ */
+router.put("/settings", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const settings = await enrichmentService.updateSettings(userId, req.body);
+ res.json(settings);
+ } catch (error) {
+ console.error("Update enrichment settings error:", error);
+ res.status(500).json({ error: "Failed to update settings" });
+ }
+});
+
+/**
+ * POST /enrichment/artist/:id
+ * Enrich a single artist
+ */
+router.post("/artist/:id", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const settings = await enrichmentService.getSettings(userId);
+
+ if (!settings.enabled) {
+ return res.status(400).json({ error: "Enrichment is not enabled" });
+ }
+
+ const enrichmentData = await enrichmentService.enrichArtist(req.params.id, settings);
+
+ if (!enrichmentData) {
+ return res.status(404).json({ error: "No enrichment data found" });
+ }
+
+ if (enrichmentData.confidence > 0.3) {
+ await enrichmentService.applyArtistEnrichment(req.params.id, enrichmentData);
+ }
+
+ res.json({
+ success: true,
+ confidence: enrichmentData.confidence,
+ data: enrichmentData,
+ });
+ } catch (error: any) {
+ console.error("Enrich artist error:", error);
+ res.status(500).json({ error: error.message || "Failed to enrich artist" });
+ }
+});
+
+/**
+ * POST /enrichment/album/:id
+ * Enrich a single album
+ */
+router.post("/album/:id", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const settings = await enrichmentService.getSettings(userId);
+
+ if (!settings.enabled) {
+ return res.status(400).json({ error: "Enrichment is not enabled" });
+ }
+
+ const enrichmentData = await enrichmentService.enrichAlbum(req.params.id, settings);
+
+ if (!enrichmentData) {
+ return res.status(404).json({ error: "No enrichment data found" });
+ }
+
+ if (enrichmentData.confidence > 0.3) {
+ await enrichmentService.applyAlbumEnrichment(req.params.id, enrichmentData);
+ }
+
+ res.json({
+ success: true,
+ confidence: enrichmentData.confidence,
+ data: enrichmentData,
+ });
+ } catch (error: any) {
+ console.error("Enrich album error:", error);
+ res.status(500).json({ error: error.message || "Failed to enrich album" });
+ }
+});
+
+/**
+ * POST /enrichment/start
+ * Start library-wide enrichment (runs in background)
+ */
+router.post("/start", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const { notificationService } = await import("../services/notificationService");
+
+ // Check if enrichment is enabled in system settings
+ const { prisma } = await import("../utils/db");
+ const systemSettings = await prisma.systemSettings.findUnique({
+ where: { id: "default" },
+ select: { autoEnrichMetadata: true },
+ });
+
+ if (!systemSettings?.autoEnrichMetadata) {
+ return res.status(400).json({ error: "Enrichment is not enabled. Enable it in settings first." });
+ }
+
+ // Get user enrichment settings or use defaults
+ const settings = await enrichmentService.getSettings(userId);
+
+ // Override enabled flag with system setting
+ settings.enabled = true;
+
+ // Send notification that enrichment is starting
+ await notificationService.notifySystem(
+ userId,
+ "Library Enrichment Started",
+ "Enriching artist metadata in the background..."
+ );
+
+ // Start enrichment in background
+ enrichmentService.enrichLibrary(userId).then(async () => {
+ // Send notification when complete
+ await notificationService.notifySystem(
+ userId,
+ "Library Enrichment Complete",
+ "All artist metadata has been enriched"
+ );
+ }).catch(async (error) => {
+ console.error("Background enrichment failed:", error);
+ await notificationService.create({
+ userId,
+ type: "error",
+ title: "Enrichment Failed",
+ message: error.message || "Failed to enrich library metadata",
+ });
+ });
+
+ res.json({
+ success: true,
+ message: "Library enrichment started in background",
+ });
+ } catch (error: any) {
+ console.error("Start enrichment error:", error);
+ res.status(500).json({ error: error.message || "Failed to start enrichment" });
+ }
+});
+
+/**
+ * PUT /library/artists/:id/metadata
+ * Update artist metadata manually
+ */
+router.put("/artists/:id/metadata", async (req, res) => {
+ try {
+ const { name, bio, genres, mbid, heroUrl } = req.body;
+
+ const updateData: any = {};
+ if (name) updateData.name = name;
+ if (bio) updateData.summary = bio;
+ if (mbid) updateData.mbid = mbid;
+ if (heroUrl) updateData.heroUrl = heroUrl;
+ if (genres) updateData.manualGenres = JSON.stringify(genres);
+
+ // Mark as manually edited
+ updateData.manuallyEdited = true;
+
+ const { prisma } = await import("../utils/db");
+ const artist = await prisma.artist.update({
+ where: { id: req.params.id },
+ data: updateData,
+ include: {
+ albums: {
+ select: {
+ id: true,
+ title: true,
+ year: true,
+ coverUrl: true,
+ },
+ },
+ },
+ });
+
+ res.json(artist);
+ } catch (error: any) {
+ console.error("Update artist metadata error:", error);
+ res.status(500).json({ error: error.message || "Failed to update artist" });
+ }
+});
+
+/**
+ * PUT /library/albums/:id/metadata
+ * Update album metadata manually
+ */
+router.put("/albums/:id/metadata", async (req, res) => {
+ try {
+ const { title, year, genres, rgMbid, coverUrl } = req.body;
+
+ const updateData: any = {};
+ if (title) updateData.title = title;
+ if (year) updateData.year = parseInt(year);
+ if (rgMbid) updateData.rgMbid = rgMbid;
+ if (coverUrl) updateData.coverUrl = coverUrl;
+ if (genres) updateData.manualGenres = JSON.stringify(genres);
+
+ // Mark as manually edited
+ updateData.manuallyEdited = true;
+
+ const { prisma } = await import("../utils/db");
+ const album = await prisma.album.update({
+ where: { id: req.params.id },
+ data: updateData,
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ tracks: {
+ select: {
+ id: true,
+ title: true,
+ trackNo: true,
+ duration: true,
+ },
+ },
+ },
+ });
+
+ res.json(album);
+ } catch (error: any) {
+ console.error("Update album metadata error:", error);
+ res.status(500).json({ error: error.message || "Failed to update album" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/homepage.ts b/backend/src/routes/homepage.ts
new file mode 100644
index 0000000..3f0e8b2
--- /dev/null
+++ b/backend/src/routes/homepage.ts
@@ -0,0 +1,187 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { redisClient } from "../utils/redis";
+
+const router = Router();
+
+// All routes require auth (session or API key)
+router.use(requireAuthOrToken);
+
+/**
+ * GET /homepage/genres
+ * Get top genres from user's library with sample albums
+ */
+router.get("/genres", async (req, res) => {
+ try {
+ const { limit = "4" } = req.query; // Get top 4 genres by default
+ const limitNum = parseInt(limit as string, 10);
+
+ // Check Redis cache first (cache for 24 hours)
+ const cacheKey = `homepage:genres:${limitNum}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(`[HOMEPAGE] Cache HIT for genres`);
+ return res.json(JSON.parse(cached));
+ }
+ } catch (cacheError) {
+ console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
+ }
+
+ console.log(
+ `[HOMEPAGE] ✗ Cache MISS for genres, fetching from database...`
+ );
+
+ // Get all albums with genres (excluding discovery albums)
+ const albums = await prisma.album.findMany({
+ where: {
+ genres: {
+ isEmpty: false, // Only albums with genres
+ },
+ location: "LIBRARY", // Exclude discovery albums
+ },
+ select: {
+ id: true,
+ title: true,
+ year: true,
+ coverUrl: true,
+ genres: true,
+ artistId: true,
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ });
+
+ // Count genre occurrences
+ const genreCounts = new Map();
+ for (const album of albums) {
+ for (const genre of album.genres) {
+ genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1);
+ }
+ }
+
+ // Get top genres
+ const topGenres = Array.from(genreCounts.entries())
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, limitNum)
+ .map(([genre]) => genre);
+
+ console.log(`[HOMEPAGE] Top genres: ${topGenres.join(", ")}`);
+
+ // For each top genre, get sample albums (up to 10)
+ const genresWithAlbums = topGenres.map((genre) => {
+ const genreAlbums = albums
+ .filter((a) => a.genres.includes(genre))
+ .slice(0, 10)
+ .map((a) => ({
+ id: a.id,
+ title: a.title,
+ year: a.year,
+ coverArt: a.coverUrl,
+ artist: {
+ id: a.artist.id,
+ name: a.artist.name,
+ },
+ }));
+
+ return {
+ genre,
+ albums: genreAlbums,
+ totalCount: genreCounts.get(genre) || 0,
+ };
+ });
+
+ // Cache for 24 hours
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ 24 * 60 * 60,
+ JSON.stringify(genresWithAlbums)
+ );
+ console.log(`[HOMEPAGE] Cached genres for 24 hours`);
+ } catch (cacheError) {
+ console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
+ }
+
+ res.json(genresWithAlbums);
+ } catch (error) {
+ console.error("Get homepage genres error:", error);
+ res.status(500).json({ error: "Failed to fetch genres" });
+ }
+});
+
+/**
+ * GET /homepage/top-podcasts
+ * Get top podcasts (most subscribed or most recent episodes)
+ */
+router.get("/top-podcasts", async (req, res) => {
+ try {
+ const { limit = "6" } = req.query; // Get top 6 podcasts by default
+ const limitNum = parseInt(limit as string, 10);
+
+ // Check Redis cache first (cache for 24 hours)
+ const cacheKey = `homepage:top-podcasts:${limitNum}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(`[HOMEPAGE] Cache HIT for top podcasts`);
+ return res.json(JSON.parse(cached));
+ }
+ } catch (cacheError) {
+ console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
+ }
+
+ console.log(
+ `[HOMEPAGE] ✗ Cache MISS for top podcasts, fetching from database...`
+ );
+
+ // Get podcasts with episode counts
+ const podcasts = await prisma.podcast.findMany({
+ take: limitNum,
+ orderBy: { createdAt: "desc" }, // Most recently added
+ select: {
+ id: true,
+ title: true,
+ author: true,
+ description: true,
+ imageUrl: true,
+ _count: {
+ select: { episodes: true },
+ },
+ },
+ });
+
+ const result = podcasts.map((podcast) => ({
+ id: podcast.id,
+ title: podcast.title,
+ author: podcast.author,
+ description: podcast.description?.substring(0, 150) + "...",
+ coverArt: podcast.imageUrl,
+ episodeCount: podcast._count.episodes,
+ }));
+
+ // Cache for 24 hours
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ 24 * 60 * 60,
+ JSON.stringify(result)
+ );
+ console.log(`[HOMEPAGE] Cached top podcasts for 24 hours`);
+ } catch (cacheError) {
+ console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
+ }
+
+ res.json(result);
+ } catch (error) {
+ console.error("Get top podcasts error:", error);
+ res.status(500).json({ error: "Failed to fetch top podcasts" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/library.ts b/backend/src/routes/library.ts
new file mode 100644
index 0000000..34a0c12
--- /dev/null
+++ b/backend/src/routes/library.ts
@@ -0,0 +1,3852 @@
+import { Router, Response } from "express";
+import { requireAuth, requireAuthOrToken } from "../middleware/auth";
+import { imageLimiter, apiLimiter } from "../middleware/rateLimiter";
+import { lastFmService } from "../services/lastfm";
+import { prisma } from "../utils/db";
+import { getEnrichmentProgress } from "../workers/enrichment";
+import { redisClient } from "../utils/redis";
+import crypto from "crypto";
+import path from "path";
+import fs from "fs";
+
+// Static imports for performance (avoid dynamic imports in hot paths)
+import { config } from "../config";
+import { fanartService } from "../services/fanart";
+import { deezerService } from "../services/deezer";
+import { musicBrainzService } from "../services/musicbrainz";
+import { coverArtService } from "../services/coverArt";
+import { getSystemSettings } from "../utils/systemSettings";
+import { AudioStreamingService } from "../services/audioStreaming";
+import { scanQueue } from "../workers/queues";
+import { organizeSingles } from "../workers/organizeSingles";
+import { enrichSimilarArtist } from "../workers/artistEnrichment";
+import { extractColorsFromImage } from "../utils/colorExtractor";
+import { dataCacheService } from "../services/dataCache";
+
+const router = Router();
+
+const applyCoverArtCorsHeaders = (res: Response, origin?: string) => {
+ if (origin) {
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ } else {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ }
+ res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
+};
+
+// All routes require auth (session or API key)
+router.use(requireAuthOrToken);
+
+// Apply API rate limiter to routes that need it
+// Skip rate limiting for high-traffic endpoints (cover-art, streaming)
+router.use((req, res, next) => {
+ // Skip rate limiting for cover-art endpoint (handled by imageLimiter separately)
+ if (req.path.startsWith("/cover-art")) {
+ return next();
+ }
+ // Skip rate limiting for streaming endpoints - audio must not be interrupted
+ if (req.path.includes("/stream")) {
+ return next();
+ }
+ // Apply API rate limiter to all other routes
+ return apiLimiter(req, res, next);
+});
+
+/**
+ * @openapi
+ * /library/scan:
+ * post:
+ * summary: Start a library scan job
+ * description: Initiates a background job to scan the music directory and index all audio files
+ * tags: [Library]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * responses:
+ * 200:
+ * description: Library scan started successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * message:
+ * type: string
+ * example: "Library scan started"
+ * jobId:
+ * type: string
+ * description: Job ID to track progress
+ * example: "123"
+ * musicPath:
+ * type: string
+ * example: "/path/to/music"
+ * 500:
+ * description: Failed to start scan
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.post("/scan", async (req, res) => {
+ try {
+
+ if (!config.music.musicPath) {
+ return res.status(500).json({
+ error: "Music path not configured. Please set MUSIC_PATH environment variable.",
+ });
+ }
+
+ // First, organize any SLSKD downloads from Docker container to music library
+ // This ensures files are moved before the scan finds them
+ try {
+ const { organizeSingles } = await import("../workers/organizeSingles");
+ console.log("[Scan] Organizing SLSKD downloads before scan...");
+ await organizeSingles();
+ console.log("[Scan] SLSKD organization complete");
+ } catch (err: any) {
+ // Not a fatal error - SLSKD might not be running or have no files
+ console.log("[Scan] SLSKD organization skipped:", err.message);
+ }
+
+ const userId = req.user?.id || "system";
+
+ // Add scan job to queue
+ const job = await scanQueue.add("scan", {
+ userId,
+ musicPath: config.music.musicPath,
+ });
+
+ res.json({
+ message: "Library scan started",
+ jobId: job.id,
+ musicPath: config.music.musicPath,
+ });
+ } catch (error) {
+ console.error("Scan trigger error:", error);
+ res.status(500).json({ error: "Failed to start scan" });
+ }
+});
+
+// GET /library/scan/status/:jobId - Check scan job status
+router.get("/scan/status/:jobId", async (req, res) => {
+ try {
+ const job = await scanQueue.getJob(req.params.jobId);
+
+ if (!job) {
+ return res.status(404).json({ error: "Job not found" });
+ }
+
+ const state = await job.getState();
+ const progress = job.progress();
+ const result = job.returnvalue;
+
+ res.json({
+ status: state,
+ progress,
+ result,
+ });
+ } catch (error) {
+ console.error("Get scan status error:", error);
+ res.status(500).json({ error: "Failed to get job status" });
+ }
+});
+
+// POST /library/organize - Manually trigger organization script
+router.post("/organize", async (req, res) => {
+ try {
+
+ // Run in background
+ organizeSingles().catch((err) => {
+ console.error("Manual organization failed:", err);
+ });
+
+ res.json({ message: "Organization started in background" });
+ } catch (error) {
+ console.error("Organization trigger error:", error);
+ res.status(500).json({ error: "Failed to start organization" });
+ }
+});
+
+// POST /library/artists/:id/enrich - Manually enrich artist metadata
+router.post("/artists/:id/enrich", async (req, res) => {
+ try {
+ const artist = await prisma.artist.findUnique({
+ where: { id: req.params.id },
+ });
+
+ if (!artist) {
+ return res.status(404).json({ error: "Artist not found" });
+ }
+
+ // Use enrichment functions
+
+ // Run enrichment in background
+ enrichSimilarArtist(artist).catch((err) => {
+ console.error(`Failed to enrich artist ${artist.name}:`, err);
+ });
+
+ res.json({ message: "Artist enrichment started in background" });
+ } catch (error) {
+ console.error("Enrich artist error:", error);
+ res.status(500).json({ error: "Failed to enrich artist" });
+ }
+});
+
+// GET /library/enrichment-progress - Get enrichment worker progress
+router.get("/enrichment-progress", async (req, res) => {
+ try {
+ const progress = await getEnrichmentProgress();
+ res.json(progress);
+ } catch (error) {
+ console.error("Failed to get enrichment progress:", error);
+ res.status(500).json({ error: "Failed to get enrichment progress" });
+ }
+});
+
+// POST /library/re-enrich-all - Re-enrich all artists with missing images (no auth required for convenience)
+router.post("/re-enrich-all", async (req, res) => {
+ try {
+ // Reset all artists that have no heroUrl to "pending"
+ const result = await prisma.artist.updateMany({
+ where: {
+ OR: [{ heroUrl: null }, { heroUrl: "" }],
+ },
+ data: {
+ enrichmentStatus: "pending",
+ lastEnriched: null,
+ },
+ });
+
+ console.log(
+ ` Reset ${result.count} artists with missing images to pending`
+ );
+
+ res.json({
+ message: `Reset ${result.count} artists for re-enrichment`,
+ count: result.count,
+ });
+ } catch (error) {
+ console.error("Failed to reset artists:", error);
+ res.status(500).json({ error: "Failed to reset artists" });
+ }
+});
+
+// GET /library/recently-listened?limit=10
+router.get("/recently-listened", async (req, res) => {
+ try {
+ const { limit = "10" } = req.query;
+ const userId = req.user!.id;
+ const limitNum = parseInt(limit as string, 10);
+
+ const [recentPlays, inProgressAudiobooks, inProgressPodcasts] =
+ await Promise.all([
+ prisma.play.findMany({
+ where: {
+ userId,
+ // Exclude pure discovery plays (only show library and kept discovery)
+ source: { in: ["LIBRARY", "DISCOVERY_KEPT"] },
+ // Also filter by album location to exclude discovery albums
+ track: {
+ album: {
+ location: "LIBRARY",
+ },
+ },
+ },
+ orderBy: { playedAt: "desc" },
+ take: limitNum * 3, // Get more than needed to account for duplicates
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ heroUrl: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ prisma.audiobookProgress.findMany({
+ where: {
+ userId,
+ isFinished: false,
+ currentTime: { gt: 0 }, // Only show if actually started
+ },
+ orderBy: { lastPlayedAt: "desc" },
+ take: Math.ceil(limitNum / 3), // Get up to 1/3 for audiobooks
+ }),
+ prisma.podcastProgress.findMany({
+ where: {
+ userId,
+ isFinished: false,
+ currentTime: { gt: 0 }, // Only show if actually started
+ },
+ orderBy: { lastPlayedAt: "desc" },
+ take: limitNum * 2, // Get extra to account for deduplication
+ include: {
+ episode: {
+ include: {
+ podcast: {
+ select: {
+ id: true,
+ title: true,
+ author: true,
+ imageUrl: true,
+ },
+ },
+ },
+ },
+ },
+ }),
+ ]);
+
+ // Deduplicate podcasts - keep only the most recently played episode per podcast
+ const seenPodcasts = new Set();
+ const uniquePodcasts = inProgressPodcasts
+ .filter((pp) => {
+ const podcastId = pp.episode.podcast.id;
+ if (seenPodcasts.has(podcastId)) {
+ return false;
+ }
+ seenPodcasts.add(podcastId);
+ return true;
+ })
+ .slice(0, Math.ceil(limitNum / 3)); // Limit to 1/3 after deduplication
+
+ // Extract unique artists and audiobooks
+ const items: any[] = [];
+ const artistsMap = new Map();
+
+ // Add music artists
+ for (const play of recentPlays) {
+ const artist = play.track.album.artist;
+ if (!artistsMap.has(artist.id)) {
+ artistsMap.set(artist.id, {
+ ...artist,
+ type: "artist",
+ lastPlayedAt: play.playedAt,
+ });
+ }
+ if (items.length >= limitNum) break;
+ }
+
+ // Combine artists, audiobooks, and podcasts
+ const combined = [
+ ...Array.from(artistsMap.values()),
+ ...inProgressAudiobooks.map((ab: any) => {
+ // For audiobooks, prefix the path with 'audiobook__' so the frontend knows to use the audiobook endpoint
+ const coverArt =
+ ab.coverUrl && !ab.coverUrl.startsWith("http")
+ ? `audiobook__${ab.coverUrl}`
+ : ab.coverUrl;
+
+ return {
+ id: ab.audiobookshelfId,
+ name: ab.title,
+ coverArt,
+ type: "audiobook",
+ author: ab.author,
+ progress:
+ ab.duration > 0
+ ? Math.round((ab.currentTime / ab.duration) * 100)
+ : 0,
+ lastPlayedAt: ab.lastPlayedAt,
+ };
+ }),
+ ...uniquePodcasts.map((pp: any) => ({
+ id: pp.episode.podcast.id,
+ episodeId: pp.episodeId,
+ name: pp.episode.podcast.title,
+ coverArt: pp.episode.podcast.imageUrl,
+ type: "podcast",
+ author: pp.episode.podcast.author,
+ progress:
+ pp.duration > 0
+ ? Math.round((pp.currentTime / pp.duration) * 100)
+ : 0,
+ lastPlayedAt: pp.lastPlayedAt,
+ })),
+ ];
+
+ // Sort by lastPlayedAt and limit
+ combined.sort(
+ (a, b) =>
+ new Date(b.lastPlayedAt).getTime() -
+ new Date(a.lastPlayedAt).getTime()
+ );
+ const limitedItems = combined.slice(0, limitNum);
+
+ // Get album counts for artists
+ const artistIds = limitedItems
+ .filter((item) => item.type === "artist")
+ .map((item) => item.id);
+ const albumCounts = await prisma.ownedAlbum.groupBy({
+ by: ["artistId"],
+ where: { artistId: { in: artistIds } },
+ _count: { rgMbid: true },
+ });
+ const albumCountMap = new Map(
+ albumCounts.map((ac) => [ac.artistId, ac._count.rgMbid])
+ );
+
+ // Add on-demand image fetching for artists without heroUrl
+ const results = await Promise.all(
+ limitedItems.map(async (item) => {
+ if (item.type === "audiobook" || item.type === "podcast") {
+ return item;
+ } else {
+ let coverArt = item.heroUrl;
+
+ // Fetch image on-demand if missing
+ if (!coverArt) {
+ console.log(
+ `[IMAGE] Fetching image on-demand for ${item.name}...`
+ );
+
+ // Check Redis cache first
+ const cacheKey = `hero-image:${item.id}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ coverArt = cached;
+ console.log(` Found cached image`);
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ // Try Fanart.tv if we have real MBID
+ if (
+ !coverArt &&
+ item.mbid &&
+ !item.mbid.startsWith("temp-")
+ ) {
+ try {
+ coverArt = await fanartService.getArtistImage(
+ item.mbid
+ );
+ } catch (err) {
+ // Fanart.tv failed, continue to next source
+ }
+ }
+
+ // Fallback to Deezer
+ if (!coverArt) {
+ try {
+ coverArt = await deezerService.getArtistImage(
+ item.name
+ );
+ } catch (err) {
+ // Deezer failed, continue to next source
+ }
+ }
+
+ // Fallback to Last.fm
+ if (!coverArt) {
+ try {
+ const validMbid =
+ item.mbid && !item.mbid.startsWith("temp-")
+ ? item.mbid
+ : undefined;
+ const lastfmInfo =
+ await lastFmService.getArtistInfo(
+ item.name,
+ validMbid
+ );
+
+ if (
+ lastfmInfo.image &&
+ lastfmInfo.image.length > 0
+ ) {
+ const largestImage =
+ lastfmInfo.image.find(
+ (img: any) =>
+ img.size === "extralarge" ||
+ img.size === "mega"
+ ) ||
+ lastfmInfo.image[
+ lastfmInfo.image.length - 1
+ ];
+
+ if (largestImage && largestImage["#text"]) {
+ coverArt = largestImage["#text"];
+ console.log(` Found Last.fm image`);
+ }
+ }
+ } catch (err) {
+ // Last.fm failed, leave as null
+ }
+ }
+
+ // Cache the result for 7 days
+ if (coverArt) {
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ 7 * 24 * 60 * 60,
+ coverArt
+ );
+ console.log(` Cached image for 7 days`);
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+ }
+ }
+
+ return {
+ ...item,
+ coverArt,
+ albumCount: albumCountMap.get(item.id) || 0,
+ };
+ }
+ })
+ );
+
+ res.json({ items: results });
+ } catch (error) {
+ console.error("Get recently listened error:", error);
+ res.status(500).json({ error: "Failed to fetch recently listened" });
+ }
+});
+
+// GET /library/recently-added?limit=10
+router.get("/recently-added", async (req, res) => {
+ try {
+ const { limit = "10" } = req.query;
+ const limitNum = parseInt(limit as string, 10);
+
+ // Get the 20 most recently added LIBRARY albums (by lastSynced timestamp)
+ // This limits "Recently Added" to actual recent additions, not the entire library
+ const recentAlbums = await prisma.album.findMany({
+ where: {
+ location: "LIBRARY",
+ tracks: { some: {} }, // Only albums with actual tracks
+ },
+ orderBy: { lastSynced: "desc" },
+ take: 20, // Hard limit to last 20 albums
+ include: {
+ artist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ heroUrl: true,
+ },
+ },
+ },
+ });
+
+ // Extract unique artists from recent albums (preserving order of most recent)
+ const artistsMap = new Map();
+ for (const album of recentAlbums) {
+ if (!artistsMap.has(album.artist.id)) {
+ artistsMap.set(album.artist.id, album.artist);
+ }
+ if (artistsMap.size >= limitNum) break;
+ }
+
+ // Get album counts for each artist (only LIBRARY albums)
+ const artistIds = Array.from(artistsMap.keys());
+ const albumCounts = await prisma.album.groupBy({
+ by: ["artistId"],
+ where: {
+ artistId: { in: artistIds },
+ location: "LIBRARY",
+ tracks: { some: {} },
+ },
+ _count: { id: true },
+ });
+ const albumCountMap = new Map(
+ albumCounts.map((ac) => [ac.artistId, ac._count.id])
+ );
+
+ // ========== ON-DEMAND IMAGE FETCHING FOR RECENTLY ADDED ==========
+ // For artists without heroUrl, fetch images on-demand
+ const artistsWithImages = await Promise.all(
+ Array.from(artistsMap.values()).map(async (artist) => {
+ let coverArt = artist.heroUrl;
+
+ if (!coverArt) {
+ console.log(
+ `[IMAGE] Fetching image on-demand for ${artist.name}...`
+ );
+
+ // Check Redis cache first
+ const cacheKey = `hero-image:${artist.id}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ coverArt = cached;
+ console.log(` Found cached image`);
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ // Try Fanart.tv if we have real MBID
+ if (
+ !coverArt &&
+ artist.mbid &&
+ !artist.mbid.startsWith("temp-")
+ ) {
+ try {
+ coverArt = await fanartService.getArtistImage(
+ artist.mbid
+ );
+ } catch (err) {
+ // Fanart.tv failed, continue to next source
+ }
+ }
+
+ // Fallback to Deezer
+ if (!coverArt) {
+ try {
+ coverArt = await deezerService.getArtistImage(
+ artist.name
+ );
+ } catch (err) {
+ // Deezer failed, continue to next source
+ }
+ }
+
+ // Fallback to Last.fm
+ if (!coverArt) {
+ try {
+ const validMbid =
+ artist.mbid && !artist.mbid.startsWith("temp-")
+ ? artist.mbid
+ : undefined;
+ const lastfmInfo =
+ await lastFmService.getArtistInfo(
+ artist.name,
+ validMbid
+ );
+
+ if (
+ lastfmInfo.image &&
+ lastfmInfo.image.length > 0
+ ) {
+ const largestImage =
+ lastfmInfo.image.find(
+ (img: any) =>
+ img.size === "extralarge" ||
+ img.size === "mega"
+ ) ||
+ lastfmInfo.image[
+ lastfmInfo.image.length - 1
+ ];
+
+ if (largestImage && largestImage["#text"]) {
+ coverArt = largestImage["#text"];
+ console.log(` Found Last.fm image`);
+ }
+ }
+ } catch (err) {
+ // Last.fm failed, leave as null
+ }
+ }
+
+ // Cache the result for 7 days
+ if (coverArt) {
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ 7 * 24 * 60 * 60,
+ coverArt
+ );
+ console.log(` Cached image for 7 days`);
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+ }
+ }
+
+ return {
+ ...artist,
+ coverArt,
+ albumCount: albumCountMap.get(artist.id) || 0,
+ };
+ })
+ );
+
+ res.json({ artists: artistsWithImages });
+ } catch (error) {
+ console.error("Get recently added error:", error);
+ res.status(500).json({ error: "Failed to fetch recently added" });
+ }
+});
+
+// GET /library/artists?query=&limit=&offset=&filter=owned|discovery|all
+router.get("/artists", async (req, res) => {
+ try {
+ const {
+ query = "",
+ limit: limitParam = "500",
+ offset: offsetParam = "0",
+ filter = "owned", // owned (default), discovery, all
+ } = req.query;
+ const limit = Math.min(parseInt(limitParam as string, 10) || 500, 1000); // Max 1000
+ const offset = parseInt(offsetParam as string, 10) || 0;
+
+ // Build where clause based on filter
+ let where: any = {
+ albums: {
+ some: {
+ tracks: { some: {} }, // Only artists with albums that have actual tracks
+ },
+ },
+ };
+
+ if (filter === "owned") {
+ // Artists with at least 1 LIBRARY album OR an OwnedAlbum record (liked discovery)
+ where.OR = [
+ {
+ albums: {
+ some: {
+ location: "LIBRARY",
+ tracks: { some: {} },
+ },
+ },
+ },
+ {
+ // Include artists with OwnedAlbum records (includes liked discovery albums)
+ ownedAlbums: {
+ some: {},
+ },
+ albums: {
+ some: {
+ tracks: { some: {} },
+ },
+ },
+ },
+ ];
+ } else if (filter === "discovery") {
+ // Artists with ONLY DISCOVERY albums (no LIBRARY albums)
+ where = {
+ AND: [
+ {
+ albums: {
+ some: {
+ location: "DISCOVER",
+ tracks: { some: {} },
+ },
+ },
+ },
+ {
+ albums: {
+ none: {
+ location: "LIBRARY",
+ },
+ },
+ },
+ ],
+ };
+ }
+ // filter === "all" uses the default (any albums with tracks)
+
+ if (query) {
+ if (where.AND) {
+ where.AND.push({ name: { contains: query as string, mode: "insensitive" } });
+ } else {
+ where.name = { contains: query as string, mode: "insensitive" };
+ }
+ }
+
+ // Determine which album location to count based on filter
+ const albumLocationFilter = filter === "discovery" ? "DISCOVER" : filter === "all" ? undefined : "LIBRARY";
+
+ const [artistsWithAlbums, total] = await Promise.all([
+ prisma.artist.findMany({
+ where,
+ skip: offset,
+ take: limit,
+ orderBy: { name: "asc" },
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ heroUrl: true,
+ albums: {
+ where: {
+ ...(albumLocationFilter ? { location: albumLocationFilter } : {}),
+ tracks: { some: {} },
+ },
+ select: {
+ id: true,
+ },
+ },
+ },
+ }),
+ prisma.artist.count({ where }),
+ ]);
+
+ // Use DataCacheService for batch image lookup (DB + Redis, no API calls for lists)
+ const imageMap = await dataCacheService.getArtistImagesBatch(
+ artistsWithAlbums.map((a) => ({ id: a.id, heroUrl: a.heroUrl }))
+ );
+
+ const artistsWithImages = artistsWithAlbums.map((artist) => {
+ const coverArt = imageMap.get(artist.id) || artist.heroUrl || null;
+ return {
+ id: artist.id,
+ mbid: artist.mbid,
+ name: artist.name,
+ heroUrl: coverArt,
+ coverArt, // Alias for frontend consistency
+ albumCount: artist.albums.length,
+ };
+ });
+
+ res.json({
+ artists: artistsWithImages,
+ total,
+ offset,
+ limit,
+ });
+ } catch (error: any) {
+ console.error("[Library] Get artists error:", error?.message || error);
+ console.error("[Library] Stack:", error?.stack);
+ res.status(500).json({
+ error: "Failed to fetch artists",
+ details: error?.message,
+ });
+ }
+});
+
+// GET /library/enrichment-diagnostics - Debug why artist images aren't populating
+router.get("/enrichment-diagnostics", async (req, res) => {
+ try {
+ // Get enrichment status breakdown
+ const statusCounts = await prisma.artist.groupBy({
+ by: ["enrichmentStatus"],
+ _count: true,
+ });
+
+ // Get artists that completed enrichment but have no heroUrl
+ const completedNoImage = await prisma.artist.count({
+ where: {
+ enrichmentStatus: "completed",
+ OR: [{ heroUrl: null }, { heroUrl: "" }],
+ },
+ });
+
+ // Get artists with temp MBIDs (can't use Fanart.tv)
+ const tempMbidCount = await prisma.artist.count({
+ where: {
+ mbid: { startsWith: "temp-" },
+ },
+ });
+
+ // Sample of artists with issues
+ const problemArtists = await prisma.artist.findMany({
+ where: {
+ enrichmentStatus: "completed",
+ OR: [{ heroUrl: null }, { heroUrl: "" }],
+ },
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ enrichmentStatus: true,
+ lastEnriched: true,
+ },
+ take: 10,
+ });
+
+ // Sample of failed artists
+ const failedArtists = await prisma.artist.findMany({
+ where: {
+ enrichmentStatus: "failed",
+ },
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ lastEnriched: true,
+ },
+ take: 10,
+ });
+
+ res.json({
+ summary: {
+ statusBreakdown: statusCounts.reduce((acc, s) => {
+ acc[s.enrichmentStatus || "unknown"] = s._count;
+ return acc;
+ }, {} as Record),
+ completedWithoutImage: completedNoImage,
+ tempMbidArtists: tempMbidCount,
+ },
+ problemArtists,
+ failedArtists,
+ suggestions: [
+ completedNoImage > 0 ? `${completedNoImage} artists completed enrichment but have no image - external APIs may be failing or rate limited` : null,
+ tempMbidCount > 0 ? `${tempMbidCount} artists have temp MBIDs - Fanart.tv won't work for them, relies on Deezer/Last.fm` : null,
+ statusCounts.find(s => s.enrichmentStatus === "pending")?._count ? "Enrichment still in progress - check logs" : null,
+ statusCounts.find(s => s.enrichmentStatus === "failed")?._count ? "Some artists failed enrichment - may need retry" : null,
+ ].filter(Boolean),
+ });
+ } catch (error: any) {
+ console.error("[Library] Enrichment diagnostics error:", error?.message);
+ res.status(500).json({ error: "Failed to get diagnostics" });
+ }
+});
+
+// POST /library/retry-enrichment - Retry failed enrichments
+router.post("/retry-enrichment", async (req, res) => {
+ try {
+ // Reset failed artists to pending so worker picks them up
+ const result = await prisma.artist.updateMany({
+ where: { enrichmentStatus: "failed" },
+ data: { enrichmentStatus: "pending" },
+ });
+
+ res.json({
+ message: `Reset ${result.count} failed artists to pending`,
+ count: result.count
+ });
+ } catch (error: any) {
+ console.error("[Library] Retry enrichment error:", error?.message);
+ res.status(500).json({ error: "Failed to retry enrichment" });
+ }
+});
+
+// GET /library/artists/:id
+router.get("/artists/:id", async (req, res) => {
+ try {
+ const idParam = req.params.id;
+
+ const artistInclude = {
+ albums: {
+ orderBy: { year: "desc" },
+ include: {
+ tracks: {
+ orderBy: { trackNo: "asc" },
+ take: 10, // Top tracks
+ include: {
+ album: {
+ select: {
+ id: true,
+ title: true,
+ coverUrl: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ ownedAlbums: true,
+ similarFrom: {
+ where: {
+ weight: {
+ gte: 0.1, // Only show 10%+ similarity (Last.fm match score)
+ },
+ },
+ take: 10,
+ orderBy: { weight: "desc" },
+ include: {
+ toArtist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ heroUrl: true,
+ },
+ },
+ },
+ },
+ };
+
+ // Try finding by ID first
+ let artist = await prisma.artist.findUnique({
+ where: { id: idParam },
+ include: artistInclude,
+ });
+
+ // If not found by ID, try by name (for URL-encoded names)
+ if (!artist) {
+ const decodedName = decodeURIComponent(idParam);
+ artist = await prisma.artist.findFirst({
+ where: {
+ name: {
+ equals: decodedName,
+ mode: "insensitive",
+ },
+ },
+ include: artistInclude,
+ });
+ }
+
+ // If not found and param looks like an MBID, try looking up by MBID
+ if (
+ !artist &&
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
+ idParam
+ )
+ ) {
+ artist = await prisma.artist.findFirst({
+ where: { mbid: idParam },
+ include: artistInclude,
+ });
+ }
+
+ if (!artist) {
+ return res.status(404).json({ error: "Artist not found" });
+ }
+
+ // ========== DISCOGRAPHY HANDLING ==========
+ // For enriched artists with ownedAlbums, skip expensive MusicBrainz calls
+ // Only fetch from MusicBrainz if the artist hasn't been enriched yet
+ let albumsWithOwnership = [];
+ const ownedRgMbids = new Set(artist.ownedAlbums.map((o) => o.rgMbid));
+ const isEnriched = artist.ownedAlbums.length > 0 || artist.heroUrl !== null;
+
+ // If artist has temp MBID, try to find real MBID by searching MusicBrainz
+ let effectiveMbid = artist.mbid;
+ if (!effectiveMbid || effectiveMbid.startsWith("temp-")) {
+ console.log(
+ ` Artist has temp/no MBID, searching MusicBrainz for ${artist.name}...`
+ );
+ try {
+ const searchResults = await musicBrainzService.searchArtist(
+ artist.name,
+ 1
+ );
+ if (searchResults.length > 0) {
+ effectiveMbid = searchResults[0].id;
+ console.log(` Found MBID: ${effectiveMbid}`);
+
+ // Update database with real MBID for future use (skip if duplicate)
+ try {
+ await prisma.artist.update({
+ where: { id: artist.id },
+ data: { mbid: effectiveMbid },
+ });
+ } catch (mbidError: any) {
+ // If MBID already exists for another artist, just log and continue
+ if (mbidError.code === "P2002") {
+ console.log(
+ `MBID ${effectiveMbid} already exists for another artist, skipping update`
+ );
+ } else {
+ console.error(
+ ` ✗ Failed to update MBID:`,
+ mbidError
+ );
+ }
+ }
+ } else {
+ console.log(
+ ` ✗ No MusicBrainz match found for ${artist.name}`
+ );
+ }
+ } catch (error) {
+ console.error(` ✗ MusicBrainz search failed:`, error);
+ }
+ }
+
+ // ========== ALWAYS include albums from database (actual owned files) ==========
+ // These are albums with actual tracks on disk - they MUST show as owned
+ const dbAlbums = artist.albums.map((album) => ({
+ ...album,
+ owned: true, // If it's in the database with tracks, user owns it!
+ coverArt: album.coverUrl,
+ source: "database" as const,
+ }));
+
+ console.log(
+ `[Artist] Found ${dbAlbums.length} albums from database (actual owned files)`
+ );
+
+ // ========== Supplement with MusicBrainz discography for "available to download" ==========
+ // Always fetch discography if we have a valid MBID - users need to see what's available
+ const hasDbAlbums = dbAlbums.length > 0;
+ const shouldFetchDiscography = effectiveMbid && !effectiveMbid.startsWith("temp-");
+
+ if (shouldFetchDiscography) {
+ try {
+ // Check Redis cache first (cache for 24 hours)
+ const discoCacheKey = `discography:${effectiveMbid}`;
+ let releaseGroups: any[] = [];
+
+ const cachedDisco = await redisClient.get(discoCacheKey);
+ if (cachedDisco && cachedDisco !== "NOT_FOUND") {
+ releaseGroups = JSON.parse(cachedDisco);
+ console.log(`[Artist] Using cached discography (${releaseGroups.length} albums)`);
+ } else {
+ console.log(`[Artist] Fetching discography from MusicBrainz...`);
+ releaseGroups = await musicBrainzService.getReleaseGroups(
+ effectiveMbid,
+ ["album", "ep"],
+ 100
+ );
+ // Cache for 24 hours
+ await redisClient.setEx(discoCacheKey, 24 * 60 * 60, JSON.stringify(releaseGroups));
+ }
+
+ console.log(
+ ` Got ${releaseGroups.length} albums from MusicBrainz (before filtering)`
+ );
+
+ // Filter out live albums, compilations, soundtracks, remixes, etc.
+ const excludedSecondaryTypes = [
+ "Live",
+ "Compilation",
+ "Soundtrack",
+ "Remix",
+ "DJ-mix",
+ "Mixtape/Street",
+ "Demo",
+ "Interview",
+ "Audio drama",
+ "Audiobook",
+ "Spokenword",
+ ];
+
+ const filteredReleaseGroups = releaseGroups.filter(
+ (rg: any) => {
+ // Keep if no secondary types (pure studio album/EP)
+ if (
+ !rg["secondary-types"] ||
+ rg["secondary-types"].length === 0
+ ) {
+ return true;
+ }
+ // Exclude if any secondary type matches our exclusion list
+ return !rg["secondary-types"].some((type: string) =>
+ excludedSecondaryTypes.includes(type)
+ );
+ }
+ );
+
+ console.log(
+ ` Filtered to ${filteredReleaseGroups.length} studio albums/EPs`
+ );
+
+ // Transform MusicBrainz release groups to album format
+ // PERFORMANCE: Only check Redis cache for covers, don't make API calls
+ // This makes artist pages load instantly after the first visit
+ const mbAlbums = await Promise.all(
+ filteredReleaseGroups.map(async (rg: any) => {
+ let coverUrl = null;
+
+ // Only check Redis cache - don't make external API calls
+ // Covers will be fetched lazily by the frontend or during enrichment
+ const cacheKey = `caa:${rg.id}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached && cached !== "NOT_FOUND") {
+ coverUrl = cached;
+ }
+ } catch (err) {
+ // Redis error, continue without cover
+ }
+
+ return {
+ id: rg.id,
+ rgMbid: rg.id,
+ title: rg.title,
+ year: rg["first-release-date"]
+ ? parseInt(
+ rg["first-release-date"].substring(0, 4)
+ )
+ : null,
+ type: rg["primary-type"],
+ coverUrl,
+ coverArt: coverUrl,
+ artistId: artist.id,
+ owned: ownedRgMbids.has(rg.id),
+ trackCount: 0,
+ tracks: [],
+ source: "musicbrainz" as const,
+ };
+ })
+ );
+
+ // Merge database albums with MusicBrainz albums
+ // Database albums take precedence (they have actual files!)
+ const dbAlbumTitles = new Set(
+ dbAlbums.map((a) => a.title.toLowerCase())
+ );
+ const mbAlbumsFiltered = mbAlbums.filter(
+ (a) => !dbAlbumTitles.has(a.title.toLowerCase())
+ );
+
+ albumsWithOwnership = [...dbAlbums, ...mbAlbumsFiltered];
+
+ console.log(
+ ` Total albums: ${albumsWithOwnership.length} (${dbAlbums.length} owned from database, ${mbAlbumsFiltered.length} from MusicBrainz)`
+ );
+ console.log(
+ ` Owned: ${
+ albumsWithOwnership.filter((a) => a.owned).length
+ }, Available: ${
+ albumsWithOwnership.filter((a) => !a.owned).length
+ }`
+ );
+ } catch (error) {
+ console.error(
+ `Failed to fetch MusicBrainz discography:`,
+ error
+ );
+ // Just use database albums
+ albumsWithOwnership = dbAlbums;
+ }
+ } else {
+ // No valid MBID - just use database albums
+ console.log(
+ `[Artist] No valid MBID, using ${dbAlbums.length} albums from database`
+ );
+ albumsWithOwnership = dbAlbums;
+ }
+
+ // Extract top tracks from library first
+ const allTracks = artist.albums.flatMap((a) => a.tracks);
+ let topTracks = allTracks
+ .sort((a, b) => (b.playCount || 0) - (a.playCount || 0))
+ .slice(0, 10);
+
+ // Get user play counts for all tracks
+ const userId = req.user!.id;
+ const trackIds = allTracks.map((t) => t.id);
+ const userPlays = await prisma.play.groupBy({
+ by: ["trackId"],
+ where: {
+ userId,
+ trackId: { in: trackIds },
+ },
+ _count: {
+ id: true,
+ },
+ });
+ const userPlayCounts = new Map(
+ userPlays.map((p) => [p.trackId, p._count.id])
+ );
+
+ // Fetch Last.fm top tracks (cached for 24 hours)
+ const topTracksCacheKey = `top-tracks:${artist.id}`;
+ try {
+ // Check cache first
+ const cachedTopTracks = await redisClient.get(topTracksCacheKey);
+ let lastfmTopTracks: any[] = [];
+
+ if (cachedTopTracks && cachedTopTracks !== "NOT_FOUND") {
+ lastfmTopTracks = JSON.parse(cachedTopTracks);
+ console.log(`[Artist] Using cached top tracks (${lastfmTopTracks.length})`);
+ } else {
+ // Cache miss - fetch from Last.fm
+ const validMbid =
+ effectiveMbid && !effectiveMbid.startsWith("temp-")
+ ? effectiveMbid
+ : "";
+ lastfmTopTracks = await lastFmService.getArtistTopTracks(
+ validMbid,
+ artist.name,
+ 10
+ );
+ // Cache for 24 hours
+ await redisClient.setEx(topTracksCacheKey, 24 * 60 * 60, JSON.stringify(lastfmTopTracks));
+ console.log(`[Artist] Cached ${lastfmTopTracks.length} top tracks`);
+ }
+
+ // For each Last.fm track, try to match with library track or add as unowned
+ const combinedTracks: any[] = [];
+
+ for (const lfmTrack of lastfmTopTracks) {
+ // Try to find matching track in library
+ const matchedTrack = allTracks.find(
+ (t) => t.title.toLowerCase() === lfmTrack.name.toLowerCase()
+ );
+
+ if (matchedTrack) {
+ // Track exists in library - include user play count
+ combinedTracks.push({
+ ...matchedTrack,
+ playCount: lfmTrack.playcount
+ ? parseInt(lfmTrack.playcount)
+ : matchedTrack.playCount,
+ listeners: lfmTrack.listeners
+ ? parseInt(lfmTrack.listeners)
+ : 0,
+ userPlayCount: userPlayCounts.get(matchedTrack.id) || 0,
+ album: {
+ ...matchedTrack.album,
+ coverArt: matchedTrack.album.coverUrl,
+ },
+ });
+ } else {
+ // Track NOT in library - add as preview-only track
+ combinedTracks.push({
+ id: `lastfm-${artist.mbid || artist.name}-${
+ lfmTrack.name
+ }`,
+ title: lfmTrack.name,
+ playCount: lfmTrack.playcount
+ ? parseInt(lfmTrack.playcount)
+ : 0,
+ listeners: lfmTrack.listeners
+ ? parseInt(lfmTrack.listeners)
+ : 0,
+ duration: lfmTrack.duration
+ ? Math.floor(parseInt(lfmTrack.duration) / 1000)
+ : 0,
+ url: lfmTrack.url,
+ album: {
+ title: lfmTrack.album?.["#text"] || "Unknown Album",
+ },
+ userPlayCount: 0,
+ // NO album.id - this indicates track is not in library
+ });
+ }
+ }
+
+ topTracks = combinedTracks.slice(0, 10);
+ } catch (error) {
+ console.error(
+ `Failed to get Last.fm top tracks for ${artist.name}:`,
+ error
+ );
+ // If Last.fm fails, add user play counts to library tracks
+ topTracks = topTracks.map((t) => ({
+ ...t,
+ userPlayCount: userPlayCounts.get(t.id) || 0,
+ album: {
+ ...t.album,
+ coverArt: t.album.coverUrl,
+ },
+ }));
+ }
+
+ // ========== HERO IMAGE FETCHING ==========
+ // Use DataCacheService: DB -> Redis -> API -> save to both
+ const heroUrl = await dataCacheService.getArtistImage(
+ artist.id,
+ artist.name,
+ effectiveMbid
+ );
+
+ // ========== ON-DEMAND SIMILAR ARTISTS FETCHING ==========
+ let similarArtists: any[] = [];
+ const similarCacheKey = `similar-artists:${artist.id}`;
+
+ if (artist.similarFrom.length === 0) {
+ // Check Redis cache first (24-hour cache)
+ const cachedSimilar = await redisClient.get(similarCacheKey);
+ if (cachedSimilar && cachedSimilar !== "NOT_FOUND") {
+ similarArtists = JSON.parse(cachedSimilar);
+ console.log(`[Artist] Using cached similar artists (${similarArtists.length})`);
+ } else {
+ // Cache miss - fetch from Last.fm
+ console.log(`[Artist] Fetching similar artists from Last.fm...`);
+
+ try {
+ const validMbid =
+ effectiveMbid && !effectiveMbid.startsWith("temp-")
+ ? effectiveMbid
+ : undefined;
+ const lastfmSimilar = await lastFmService.getSimilarArtists(
+ validMbid,
+ artist.name,
+ 10
+ );
+
+ // Fetch images in parallel (Deezer only - fastest source)
+ const similarWithImages = await Promise.all(
+ lastfmSimilar.map(async (s: any) => {
+ let image = null;
+ try {
+ image = await deezerService.getArtistImage(s.name);
+ } catch (err) {
+ // Deezer failed, leave null
+ }
+
+ return {
+ id: s.name,
+ name: s.name,
+ mbid: s.mbid || null,
+ coverArt: image,
+ albumCount: 0,
+ ownedAlbumCount: 0,
+ weight: s.match,
+ };
+ })
+ );
+
+ similarArtists = similarWithImages;
+
+ // Cache for 24 hours
+ await redisClient.setEx(similarCacheKey, 24 * 60 * 60, JSON.stringify(similarArtists));
+ console.log(`[Artist] Cached ${similarArtists.length} similar artists`);
+ } catch (error) {
+ console.error(`[Artist] Failed to fetch similar artists:`, error);
+ similarArtists = [];
+ }
+ }
+ } else {
+ // Use enriched data from database
+ console.log(
+ `[Artist] Using ${artist.similarFrom.length} similar artists from database`
+ );
+
+ // Format similar artists with coverArt and album counts
+ const similarArtistIds = artist.similarFrom.map(
+ (s) => s.toArtist.id
+ );
+
+ console.log(
+ `Fetching album counts for ${similarArtistIds.length} similar artists...`
+ );
+
+ // Count TOTAL albums in discography (from Album - enriched MusicBrainz data)
+ const discographyCounts = await prisma.album.groupBy({
+ by: ["artistId"],
+ where: { artistId: { in: similarArtistIds } },
+ _count: { rgMbid: true },
+ });
+ const discographyCountMap = new Map(
+ discographyCounts.map((ac) => [ac.artistId, ac._count.rgMbid])
+ );
+ console.log(
+ `Discography counts: ${discographyCountMap.size} artists have albums`
+ );
+
+ // Count albums USER OWNS (from OwnedAlbum - tracking table)
+ const userLibraryCounts = await prisma.ownedAlbum.groupBy({
+ by: ["artistId"],
+ where: { artistId: { in: similarArtistIds } },
+ _count: { rgMbid: true },
+ });
+ const userLibraryCountMap = new Map(
+ userLibraryCounts.map((ac) => [ac.artistId, ac._count.rgMbid])
+ );
+ console.log(
+ `User library counts: ${userLibraryCountMap.size} artists are owned`
+ );
+
+ // Use DataCacheService for batch image lookup
+ const similarImageMap = await dataCacheService.getArtistImagesBatch(
+ artist.similarFrom.map((s) => ({
+ id: s.toArtist.id,
+ heroUrl: s.toArtist.heroUrl,
+ }))
+ );
+
+ similarArtists = artist.similarFrom.map((s) => {
+ const albumCount = discographyCountMap.get(s.toArtist.id) || 0;
+ const ownedAlbumCount = userLibraryCountMap.get(s.toArtist.id) || 0;
+ const coverArt = similarImageMap.get(s.toArtist.id) || s.toArtist.heroUrl || null;
+
+ return {
+ ...s.toArtist,
+ coverArt,
+ albumCount,
+ ownedAlbumCount,
+ weight: s.weight,
+ };
+ });
+ }
+
+ res.json({
+ ...artist,
+ coverArt: heroUrl, // Use fetched hero image (falls back to artist.heroUrl)
+ albums: albumsWithOwnership,
+ topTracks,
+ similarArtists,
+ });
+ } catch (error) {
+ console.error("Get artist error:", error);
+ res.status(500).json({ error: "Failed to fetch artist" });
+ }
+});
+
+// GET /library/albums?artistId=&limit=&offset=&filter=owned|discovery|all
+router.get("/albums", async (req, res) => {
+ try {
+ const {
+ artistId,
+ limit: limitParam = "500",
+ offset: offsetParam = "0",
+ filter = "owned", // owned (default), discovery, all
+ } = req.query;
+ const limit = Math.min(parseInt(limitParam as string, 10) || 500, 1000); // Max 1000
+ const offset = parseInt(offsetParam as string, 10) || 0;
+
+ let where: any = {
+ tracks: { some: {} }, // Only albums with tracks
+ };
+
+ // Apply location filter
+ if (filter === "owned") {
+ // Get all owned album rgMbids (includes liked discovery albums)
+ const ownedAlbumMbids = await prisma.ownedAlbum.findMany({
+ select: { rgMbid: true },
+ });
+ const ownedMbids = ownedAlbumMbids.map(oa => oa.rgMbid);
+
+ // Albums with LIBRARY location OR rgMbid in OwnedAlbum
+ where.OR = [
+ { location: "LIBRARY", tracks: { some: {} } },
+ { rgMbid: { in: ownedMbids }, tracks: { some: {} } },
+ ];
+ } else if (filter === "discovery") {
+ where.location = "DISCOVER";
+ }
+ // filter === "all" shows all locations
+
+ // If artistId is provided, filter by artist
+ if (artistId) {
+ if (where.OR) {
+ // If we have OR conditions, wrap with AND
+ where = {
+ AND: [
+ { OR: where.OR },
+ { artistId: artistId as string }
+ ]
+ };
+ } else {
+ where.artistId = artistId as string;
+ }
+ }
+
+ const [albumsData, total] = await Promise.all([
+ prisma.album.findMany({
+ where,
+ skip: offset,
+ take: limit,
+ orderBy: { year: "desc" },
+ include: {
+ artist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ },
+ },
+ },
+ }),
+ prisma.album.count({ where }),
+ ]);
+
+ // Normalize coverArt field for frontend
+ const albums = albumsData.map((album) => ({
+ ...album,
+ coverArt: album.coverUrl,
+ }));
+
+ res.json({
+ albums,
+ total,
+ offset,
+ limit,
+ });
+ } catch (error: any) {
+ console.error("[Library] Get albums error:", error?.message || error);
+ console.error("[Library] Stack:", error?.stack);
+ res.status(500).json({
+ error: "Failed to fetch albums",
+ details: error?.message,
+ });
+ }
+});
+
+// GET /library/albums/:id
+router.get("/albums/:id", async (req, res) => {
+ try {
+ const idParam = req.params.id;
+
+ // Try finding by ID first
+ let album = await prisma.album.findUnique({
+ where: { id: idParam },
+ include: {
+ artist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ },
+ },
+ tracks: {
+ orderBy: { trackNo: "asc" },
+ },
+ },
+ });
+
+ // If not found by ID, try by rgMbid (for discovery albums)
+ if (!album) {
+ album = await prisma.album.findFirst({
+ where: { rgMbid: idParam },
+ include: {
+ artist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ },
+ },
+ tracks: {
+ orderBy: { trackNo: "asc" },
+ },
+ },
+ });
+ }
+
+ if (!album) {
+ return res.status(404).json({ error: "Album not found" });
+ }
+
+ // Check ownership
+ const owned = await prisma.ownedAlbum.findUnique({
+ where: {
+ artistId_rgMbid: {
+ artistId: album.artistId,
+ rgMbid: album.rgMbid,
+ },
+ },
+ });
+
+ res.json({
+ ...album,
+ owned: !!owned,
+ coverArt: album.coverUrl,
+ });
+ } catch (error) {
+ console.error("Get album error:", error);
+ res.status(500).json({ error: "Failed to fetch album" });
+ }
+});
+
+// GET /library/tracks?albumId=&limit=100
+router.get("/tracks", async (req, res) => {
+ try {
+ const { albumId, limit = "100" } = req.query;
+ const limitNum = parseInt(limit as string, 10);
+
+ const where: any = {};
+ if (albumId) {
+ where.albumId = albumId as string;
+ }
+
+ const tracksData = await prisma.track.findMany({
+ where,
+ take: limitNum,
+ orderBy: albumId ? { trackNo: "asc" } : { id: "desc" },
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // Add coverArt field to albums
+ const tracks = tracksData.map((track) => ({
+ ...track,
+ album: {
+ ...track.album,
+ coverArt: track.album.coverUrl,
+ },
+ }));
+
+ res.json({ tracks });
+ } catch (error) {
+ console.error("Get tracks error:", error);
+ res.status(500).json({ error: "Failed to fetch tracks" });
+ }
+});
+
+// GET /library/cover-art/:id?size= or GET /library/cover-art?url=&size=
+// Apply lenient image limiter (500 req/min) instead of general API limiter (100 req/15min)
+router.get("/cover-art/:id?", imageLimiter, async (req, res) => {
+ try {
+ const { size, url } = req.query;
+ let coverUrl: string;
+ let isAudiobook = false;
+
+ // Check if a full URL was provided as a query parameter
+ if (url) {
+ const decodedUrl = decodeURIComponent(url as string);
+
+ // Check if this is an audiobook cover (prefixed with "audiobook__")
+ if (decodedUrl.startsWith("audiobook__")) {
+ isAudiobook = true;
+ const audiobookPath = decodedUrl.replace("audiobook__", "");
+
+ // Get Audiobookshelf settings
+ const settings = await getSystemSettings();
+ const audiobookshelfUrl =
+ settings?.audiobookshelfUrl ||
+ process.env.AUDIOBOOKSHELF_URL ||
+ "";
+ const audiobookshelfApiKey =
+ settings?.audiobookshelfApiKey ||
+ process.env.AUDIOBOOKSHELF_API_KEY ||
+ "";
+ const audiobookshelfBaseUrl = audiobookshelfUrl.replace(
+ /\/$/,
+ ""
+ );
+
+ coverUrl = `${audiobookshelfBaseUrl}/api/${audiobookPath}`;
+
+ // Fetch with authentication
+ console.log(
+ `[COVER-ART] Fetching audiobook cover: ${coverUrl.substring(
+ 0,
+ 100
+ )}...`
+ );
+ const imageResponse = await fetch(coverUrl, {
+ headers: {
+ Authorization: `Bearer ${audiobookshelfApiKey}`,
+ "User-Agent": "Lidify/1.0",
+ },
+ });
+
+ if (!imageResponse.ok) {
+ console.error(
+ `[COVER-ART] Failed to fetch audiobook cover: ${coverUrl} (${imageResponse.status} ${imageResponse.statusText})`
+ );
+ return res
+ .status(404)
+ .json({ error: "Audiobook cover art not found" });
+ }
+
+ const buffer = await imageResponse.arrayBuffer();
+ const imageBuffer = Buffer.from(buffer);
+ const contentType = imageResponse.headers.get("content-type");
+
+ if (contentType) {
+ res.setHeader("Content-Type", contentType);
+ }
+ applyCoverArtCorsHeaders(
+ res,
+ req.headers.origin as string | undefined
+ );
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=31536000, immutable"
+ );
+
+ return res.send(imageBuffer);
+ }
+
+ // Check if this is a native cover (prefixed with "native:")
+ if (decodedUrl.startsWith("native:")) {
+ const nativePath = decodedUrl.replace("native:", "");
+
+ const coverCachePath = path.join(
+ config.music.transcodeCachePath,
+ "../covers",
+ nativePath
+ );
+
+ console.log(
+ `[COVER-ART] Serving native cover: ${coverCachePath}`
+ );
+
+ // Check if file exists
+ if (!fs.existsSync(coverCachePath)) {
+ console.error(
+ `[COVER-ART] Native cover not found: ${coverCachePath}`
+ );
+ return res
+ .status(404)
+ .json({ error: "Cover art not found" });
+ }
+
+ // Serve the file directly
+ const requestOrigin = req.headers.origin;
+ const headers: Record = {
+ "Content-Type": "image/jpeg", // Assume JPEG for now
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ };
+ if (requestOrigin) {
+ headers["Access-Control-Allow-Origin"] = requestOrigin;
+ headers["Access-Control-Allow-Credentials"] = "true";
+ } else {
+ headers["Access-Control-Allow-Origin"] = "*";
+ }
+
+ return res.sendFile(coverCachePath, {
+ headers,
+ });
+ }
+
+ coverUrl = decodedUrl;
+ } else {
+ // Otherwise use the ID from the path parameter
+ const coverId = req.params.id;
+ if (!coverId) {
+ return res
+ .status(400)
+ .json({ error: "No cover ID or URL provided" });
+ }
+
+ const decodedId = decodeURIComponent(coverId);
+
+ // Check if this is a native cover (prefixed with "native:")
+ if (decodedId.startsWith("native:")) {
+ const nativePath = decodedId.replace("native:", "");
+
+ const coverCachePath = path.join(
+ config.music.transcodeCachePath,
+ "../covers",
+ nativePath
+ );
+
+ // Check if file exists
+ if (fs.existsSync(coverCachePath)) {
+ // Serve the file directly
+ const requestOrigin = req.headers.origin;
+ const headers: Record = {
+ "Content-Type": "image/jpeg",
+ "Cache-Control": "public, max-age=31536000, immutable",
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ };
+ if (requestOrigin) {
+ headers["Access-Control-Allow-Origin"] = requestOrigin;
+ headers["Access-Control-Allow-Credentials"] = "true";
+ } else {
+ headers["Access-Control-Allow-Origin"] = "*";
+ }
+
+ return res.sendFile(coverCachePath, {
+ headers,
+ });
+ }
+
+ // Native cover file missing - try to find album and fetch from Deezer
+ console.warn(
+ `[COVER-ART] Native cover not found: ${coverCachePath}, trying Deezer fallback`
+ );
+
+ // Extract album ID from the path (format: albumId.jpg)
+ const albumId = nativePath.replace(".jpg", "");
+ try {
+ const album = await prisma.album.findUnique({
+ where: { id: albumId },
+ include: { artist: true },
+ });
+
+ if (album && album.artist) {
+ const deezerCover = await deezerService.getAlbumCover(
+ album.artist.name,
+ album.title
+ );
+
+ if (deezerCover) {
+ // Update album with Deezer cover
+ await prisma.album.update({
+ where: { id: albumId },
+ data: { coverUrl: deezerCover },
+ });
+
+ // Redirect to the Deezer cover
+ return res.redirect(deezerCover);
+ }
+ }
+ } catch (error) {
+ console.error(
+ `[COVER-ART] Failed to fetch Deezer fallback for ${albumId}:`,
+ error
+ );
+ }
+
+ return res.status(404).json({ error: "Cover art not found" });
+ }
+
+ // Check if this is an audiobook cover (prefixed with "audiobook__")
+ if (decodedId.startsWith("audiobook__")) {
+ isAudiobook = true;
+ const audiobookPath = decodedId.replace("audiobook__", "");
+
+ // Get Audiobookshelf settings
+ const settings = await getSystemSettings();
+ const audiobookshelfUrl =
+ settings?.audiobookshelfUrl ||
+ process.env.AUDIOBOOKSHELF_URL ||
+ "";
+ const audiobookshelfApiKey =
+ settings?.audiobookshelfApiKey ||
+ process.env.AUDIOBOOKSHELF_API_KEY ||
+ "";
+ const audiobookshelfBaseUrl = audiobookshelfUrl.replace(
+ /\/$/,
+ ""
+ );
+
+ coverUrl = `${audiobookshelfBaseUrl}/api/${audiobookPath}`;
+
+ // Fetch with authentication
+ console.log(
+ `[COVER-ART] Fetching audiobook cover: ${coverUrl.substring(
+ 0,
+ 100
+ )}...`
+ );
+ const imageResponse = await fetch(coverUrl, {
+ headers: {
+ Authorization: `Bearer ${audiobookshelfApiKey}`,
+ "User-Agent": "Lidify/1.0",
+ },
+ });
+
+ if (!imageResponse.ok) {
+ console.error(
+ `[COVER-ART] Failed to fetch audiobook cover: ${coverUrl} (${imageResponse.status} ${imageResponse.statusText})`
+ );
+ return res
+ .status(404)
+ .json({ error: "Audiobook cover art not found" });
+ }
+
+ const buffer = await imageResponse.arrayBuffer();
+ const imageBuffer = Buffer.from(buffer);
+ const contentType = imageResponse.headers.get("content-type");
+
+ if (contentType) {
+ res.setHeader("Content-Type", contentType);
+ }
+ applyCoverArtCorsHeaders(
+ res,
+ req.headers.origin as string | undefined
+ );
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=31536000, immutable"
+ );
+
+ return res.send(imageBuffer);
+ }
+ // Check if coverId is already a full URL (from Cover Art Archive or elsewhere)
+ else if (
+ decodedId.startsWith("http://") ||
+ decodedId.startsWith("https://")
+ ) {
+ coverUrl = decodedId;
+ } else {
+ // Invalid cover ID format
+ return res
+ .status(400)
+ .json({ error: "Invalid cover ID format" });
+ }
+ }
+
+ // Create cache key from URL + size
+ const cacheKey = `cover-art:${crypto
+ .createHash("md5")
+ .update(`${coverUrl}-${size || "original"}`)
+ .digest("hex")}`;
+
+ // Try to get from Redis cache first
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ const cachedData = JSON.parse(cached);
+
+ // Check if this is a cached 404
+ if (cachedData.notFound) {
+ console.log(
+ `[COVER-ART] Cached 404 for ${coverUrl.substring(
+ 0,
+ 60
+ )}...`
+ );
+ return res
+ .status(404)
+ .json({ error: "Cover art not found" });
+ }
+
+ console.log(
+ `[COVER-ART] Cache HIT for ${coverUrl.substring(0, 60)}...`
+ );
+ const imageBuffer = Buffer.from(cachedData.data, "base64");
+
+ // Check if client has cached version
+ if (req.headers["if-none-match"] === cachedData.etag) {
+ console.log(`[COVER-ART] Client has cached version (304)`);
+ return res.status(304).end();
+ }
+
+ // Set headers and send cached image
+ if (cachedData.contentType) {
+ res.setHeader("Content-Type", cachedData.contentType);
+ }
+ applyCoverArtCorsHeaders(
+ res,
+ req.headers.origin as string | undefined
+ );
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=31536000, immutable"
+ );
+ res.setHeader("ETag", cachedData.etag);
+ return res.send(imageBuffer);
+ } else {
+ console.log(
+ `[COVER-ART] ✗ Cache MISS for ${coverUrl.substring(
+ 0,
+ 60
+ )}...`
+ );
+ }
+ } catch (cacheError) {
+ console.warn("[COVER-ART] Redis cache read error:", cacheError);
+ }
+
+ // Fetch the image and proxy it to avoid CORS issues
+ console.log(`[COVER-ART] Fetching: ${coverUrl.substring(0, 100)}...`);
+ const imageResponse = await fetch(coverUrl, {
+ headers: {
+ "User-Agent": "Lidify/1.0",
+ },
+ });
+ if (!imageResponse.ok) {
+ console.error(
+ `[COVER-ART] Failed to fetch: ${coverUrl} (${imageResponse.status} ${imageResponse.statusText})`
+ );
+
+ // Cache 404s for 1 hour to avoid repeatedly trying to fetch missing images
+ if (imageResponse.status === 404) {
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ 60 * 60, // 1 hour
+ JSON.stringify({ notFound: true })
+ );
+ console.log(`[COVER-ART] Cached 404 response for 1 hour`);
+ } catch (cacheError) {
+ console.warn(
+ "[COVER-ART] Redis cache write error:",
+ cacheError
+ );
+ }
+ }
+
+ return res.status(404).json({ error: "Cover art not found" });
+ }
+ console.log(`[COVER-ART] Successfully fetched, caching...`);
+
+ const buffer = await imageResponse.arrayBuffer();
+ const imageBuffer = Buffer.from(buffer);
+
+ // Generate ETag from content
+ const etag = crypto.createHash("md5").update(imageBuffer).digest("hex");
+
+ // Cache in Redis for 7 days
+ try {
+ const contentType = imageResponse.headers.get("content-type");
+ await redisClient.setEx(
+ cacheKey,
+ 7 * 24 * 60 * 60, // 7 days
+ JSON.stringify({
+ etag,
+ contentType,
+ data: imageBuffer.toString("base64"),
+ })
+ );
+ } catch (cacheError) {
+ console.warn("Redis cache write error:", cacheError);
+ }
+
+ // Check if client has cached version
+ if (req.headers["if-none-match"] === etag) {
+ return res.status(304).end();
+ }
+
+ // Set appropriate headers
+ const contentType = imageResponse.headers.get("content-type");
+ if (contentType) {
+ res.setHeader("Content-Type", contentType);
+ }
+
+ // Set aggressive caching headers
+ applyCoverArtCorsHeaders(res, req.headers.origin as string | undefined);
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); // Cache for 1 year
+ res.setHeader("ETag", etag);
+
+ // Send the image
+ res.send(imageBuffer);
+ } catch (error) {
+ console.error("Get cover art error:", error);
+ res.status(500).json({ error: "Failed to fetch cover art" });
+ }
+});
+
+// GET /library/album-cover/:mbid - Fetch and cache album cover by MBID
+// This is called lazily by the frontend when an album doesn't have a cached cover
+router.get("/album-cover/:mbid", imageLimiter, async (req, res) => {
+ try {
+ const { mbid } = req.params;
+
+ if (!mbid || mbid.startsWith("temp-")) {
+ return res.status(400).json({ error: "Valid MBID required" });
+ }
+
+ // Fetch from Cover Art Archive (this uses caching internally)
+ const coverUrl = await coverArtService.getCoverArt(mbid);
+
+ if (!coverUrl) {
+ // Return 204 No Content instead of 404 to avoid console spam
+ // Cover Art Archive doesn't have covers for all albums
+ return res.status(204).send();
+ }
+
+ res.json({ coverUrl });
+ } catch (error) {
+ console.error("Get album cover error:", error);
+ res.status(500).json({ error: "Failed to fetch cover art" });
+ }
+});
+
+// GET /library/cover-art-colors?url= - Extract colors from a cover art URL
+router.get("/cover-art-colors", imageLimiter, async (req, res) => {
+ try {
+ const { url } = req.query;
+
+ if (!url) {
+ return res.status(400).json({ error: "URL parameter required" });
+ }
+
+ const imageUrl = decodeURIComponent(url as string);
+
+ // Handle placeholder images - return default fallback colors
+ if (
+ imageUrl.includes("placeholder") ||
+ imageUrl.startsWith("/placeholder")
+ ) {
+ console.log(
+ `[COLORS] Placeholder image detected, returning fallback colors`
+ );
+ return res.json({
+ vibrant: "#1db954",
+ darkVibrant: "#121212",
+ lightVibrant: "#181818",
+ muted: "#535353",
+ darkMuted: "#121212",
+ lightMuted: "#b3b3b3",
+ });
+ }
+
+ // Create cache key for colors
+ const cacheKey = `colors:${crypto
+ .createHash("md5")
+ .update(imageUrl)
+ .digest("hex")}`;
+
+ // Try to get from Redis cache first
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(
+ `[COLORS] Cache HIT for ${imageUrl.substring(0, 60)}...`
+ );
+ return res.json(JSON.parse(cached));
+ } else {
+ console.log(
+ `[COLORS] ✗ Cache MISS for ${imageUrl.substring(0, 60)}...`
+ );
+ }
+ } catch (cacheError) {
+ console.warn("[COLORS] Redis cache read error:", cacheError);
+ }
+
+ // Fetch the image
+ console.log(
+ `[COLORS] Fetching image: ${imageUrl.substring(0, 100)}...`
+ );
+ const imageResponse = await fetch(imageUrl, {
+ headers: {
+ "User-Agent": "Lidify/1.0",
+ },
+ });
+
+ if (!imageResponse.ok) {
+ console.error(
+ `[COLORS] Failed to fetch image: ${imageUrl} (${imageResponse.status})`
+ );
+ return res.status(404).json({ error: "Image not found" });
+ }
+
+ const buffer = await imageResponse.arrayBuffer();
+ const imageBuffer = Buffer.from(buffer);
+
+ // Extract colors using sharp
+ const colors = await extractColorsFromImage(imageBuffer);
+
+ console.log(`[COLORS] Extracted colors:`, colors);
+
+ // Cache the result for 30 days
+ try {
+ await redisClient.setEx(
+ cacheKey,
+ 30 * 24 * 60 * 60, // 30 days
+ JSON.stringify(colors)
+ );
+ console.log(`[COLORS] Cached colors for 30 days`);
+ } catch (cacheError) {
+ console.warn("[COLORS] Redis cache write error:", cacheError);
+ }
+
+ res.json(colors);
+ } catch (error) {
+ console.error("Extract colors error:", error);
+ res.status(500).json({ error: "Failed to extract colors" });
+ }
+});
+
+// GET /library/tracks/:id/stream
+router.get("/tracks/:id/stream", async (req, res) => {
+ try {
+ console.log("[STREAM] Request received for track:", req.params.id);
+ const { quality } = req.query;
+ const userId = req.user?.id;
+
+ if (!userId) {
+ console.log("[STREAM] No userId in session - unauthorized");
+ return res.status(401).json({ error: "Unauthorized" });
+ }
+
+ const track = await prisma.track.findUnique({
+ where: { id: req.params.id },
+ });
+
+ if (!track) {
+ console.log("[STREAM] Track not found");
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Log play start - only if this is a new playback session
+ const recentPlay = await prisma.play.findFirst({
+ where: {
+ userId,
+ trackId: track.id,
+ playedAt: {
+ gte: new Date(Date.now() - 30 * 1000),
+ },
+ },
+ orderBy: { playedAt: "desc" },
+ });
+
+ if (!recentPlay) {
+ await prisma.play.create({
+ data: {
+ userId,
+ trackId: track.id,
+ },
+ });
+ console.log("[STREAM] Logged new play for track:", track.title);
+ }
+
+ // Get user's quality preference
+ let requestedQuality: string = "medium";
+ if (quality) {
+ requestedQuality = quality as string;
+ } else {
+ const settings = await prisma.userSettings.findUnique({
+ where: { userId },
+ });
+ requestedQuality = settings?.playbackQuality || "medium";
+ }
+
+ const ext = track.filePath ? path.extname(track.filePath).toLowerCase() : "";
+ console.log(`[STREAM] Quality: requested=${quality || 'default'}, using=${requestedQuality}, format=${ext}`);
+
+ // === NATIVE FILE STREAMING ===
+ // Check if track has native file path
+ if (track.filePath && track.fileModified) {
+ try {
+ // Initialize streaming service
+ const streamingService = new AudioStreamingService(
+ config.music.musicPath,
+ config.music.transcodeCachePath,
+ config.music.transcodeCacheMaxGb
+ );
+
+ // Get absolute path to source file
+ // Normalize path separators for cross-platform compatibility (Windows -> Linux)
+ const normalizedFilePath = track.filePath.replace(/\\/g, '/');
+ const absolutePath = path.join(
+ config.music.musicPath,
+ normalizedFilePath
+ );
+
+ console.log(
+ `[STREAM] Using native file: ${track.filePath} (${requestedQuality})`
+ );
+
+ // Get stream file (either original or transcoded)
+ const { filePath, mimeType } =
+ await streamingService.getStreamFilePath(
+ track.id,
+ requestedQuality as any,
+ track.fileModified,
+ absolutePath
+ );
+
+ // Stream file with range support
+ console.log(`[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}`);
+
+ res.sendFile(filePath, {
+ headers: {
+ "Content-Type": mimeType,
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "public, max-age=31536000",
+ "Access-Control-Allow-Origin":
+ req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ },
+ }, (err) => {
+ // Always destroy the streaming service to clean up intervals
+ streamingService.destroy();
+ if (err) {
+ console.error(`[STREAM] sendFile error:`, err);
+ } else {
+ console.log(`[STREAM] File sent successfully: ${path.basename(filePath)}`);
+ }
+ });
+
+ return;
+ } catch (err: any) {
+ // If FFmpeg not found, try original quality instead
+ if (
+ err.code === "FFMPEG_NOT_FOUND" &&
+ requestedQuality !== "original"
+ ) {
+ console.warn(
+ `[STREAM] FFmpeg not available, falling back to original quality`
+ );
+ const fallbackFilePath = track.filePath.replace(/\\/g, '/');
+ const absolutePath = path.join(
+ config.music.musicPath,
+ fallbackFilePath
+ );
+
+ const streamingService = new AudioStreamingService(
+ config.music.musicPath,
+ config.music.transcodeCachePath,
+ config.music.transcodeCacheMaxGb
+ );
+
+ const { filePath, mimeType } =
+ await streamingService.getStreamFilePath(
+ track.id,
+ "original",
+ track.fileModified,
+ absolutePath
+ );
+
+ res.sendFile(filePath, {
+ headers: {
+ "Content-Type": mimeType,
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "public, max-age=31536000",
+ "Access-Control-Allow-Origin":
+ req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ },
+ }, (err) => {
+ // Always destroy the streaming service to clean up intervals
+ streamingService.destroy();
+ if (err) {
+ console.error(`[STREAM] sendFile fallback error:`, err);
+ }
+ });
+ return;
+ }
+
+ console.error("[STREAM] Native streaming failed:", err.message);
+ return res
+ .status(500)
+ .json({ error: "Failed to stream track" });
+ }
+ }
+
+ // No file path available
+ console.log("[STREAM] Track has no file path - unavailable");
+ return res.status(404).json({ error: "Track not available" });
+ } catch (error) {
+ console.error("Stream track error:", error);
+ res.status(500).json({ error: "Failed to stream track" });
+ }
+});
+
+// GET /library/tracks/:id
+router.get("/tracks/:id", async (req, res) => {
+ try {
+ const track = await prisma.track.findUnique({
+ where: { id: req.params.id },
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!track) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Transform to match frontend Track interface: artist at top level
+ const formattedTrack = {
+ id: track.id,
+ title: track.title,
+ artist: {
+ name: track.album?.artist?.name || "Unknown Artist",
+ id: track.album?.artist?.id,
+ },
+ album: {
+ title: track.album?.title || "Unknown Album",
+ coverArt: track.album?.coverUrl,
+ id: track.album?.id,
+ },
+ duration: track.duration,
+ };
+
+ res.json(formattedTrack);
+ } catch (error) {
+ console.error("Get track error:", error);
+ res.status(500).json({ error: "Failed to fetch track" });
+ }
+});
+
+// DELETE /library/tracks/:id
+router.delete("/tracks/:id", async (req, res) => {
+ try {
+ const track = await prisma.track.findUnique({
+ where: { id: req.params.id },
+ include: {
+ album: {
+ include: {
+ artist: true,
+ },
+ },
+ },
+ });
+
+ if (!track) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Delete file from filesystem if path is available
+ if (track.filePath) {
+ try {
+
+ const absolutePath = path.join(
+ config.music.musicPath,
+ track.filePath
+ );
+
+ if (fs.existsSync(absolutePath)) {
+ fs.unlinkSync(absolutePath);
+ console.log(`[DELETE] Deleted file: ${absolutePath}`);
+ }
+ } catch (err) {
+ console.warn("[DELETE] Could not delete file:", err);
+ // Continue with database deletion even if file deletion fails
+ }
+ }
+
+ // Delete from database (cascade will handle related records)
+ await prisma.track.delete({
+ where: { id: track.id },
+ });
+
+ console.log(`[DELETE] Deleted track: ${track.title}`);
+
+ res.json({ message: "Track deleted successfully" });
+ } catch (error) {
+ console.error("Delete track error:", error);
+ res.status(500).json({ error: "Failed to delete track" });
+ }
+});
+
+// DELETE /library/albums/:id
+router.delete("/albums/:id", async (req, res) => {
+ try {
+ const album = await prisma.album.findUnique({
+ where: { id: req.params.id },
+ include: {
+ artist: true,
+ tracks: {
+ include: {
+ album: true,
+ },
+ },
+ },
+ });
+
+ if (!album) {
+ return res.status(404).json({ error: "Album not found" });
+ }
+
+
+ // Delete all track files
+ let deletedFiles = 0;
+ for (const track of album.tracks) {
+ if (track.filePath) {
+ try {
+ const absolutePath = path.join(
+ config.music.musicPath,
+ track.filePath
+ );
+
+ if (fs.existsSync(absolutePath)) {
+ fs.unlinkSync(absolutePath);
+ deletedFiles++;
+ }
+ } catch (err) {
+ console.warn("[DELETE] Could not delete file:", err);
+ }
+ }
+ }
+
+ // Try to delete album folder if empty
+ try {
+ const artistName = album.artist.name;
+ const albumFolder = path.join(
+ config.music.musicPath,
+ artistName,
+ album.title
+ );
+
+ if (fs.existsSync(albumFolder)) {
+ const files = fs.readdirSync(albumFolder);
+ if (files.length === 0) {
+ fs.rmdirSync(albumFolder);
+ console.log(
+ `[DELETE] Deleted empty album folder: ${albumFolder}`
+ );
+ }
+ }
+ } catch (err) {
+ console.warn("[DELETE] Could not delete album folder:", err);
+ }
+
+ // Delete from database (cascade will delete tracks)
+ await prisma.album.delete({
+ where: { id: album.id },
+ });
+
+ console.log(
+ `[DELETE] Deleted album: ${album.title} (${deletedFiles} files)`
+ );
+
+ res.json({
+ message: "Album deleted successfully",
+ deletedFiles,
+ });
+ } catch (error) {
+ console.error("Delete album error:", error);
+ res.status(500).json({ error: "Failed to delete album" });
+ }
+});
+
+// DELETE /library/artists/:id
+router.delete("/artists/:id", async (req, res) => {
+ try {
+ const artist = await prisma.artist.findUnique({
+ where: { id: req.params.id },
+ include: {
+ albums: {
+ include: {
+ tracks: true,
+ },
+ },
+ },
+ });
+
+ if (!artist) {
+ return res.status(404).json({ error: "Artist not found" });
+ }
+
+
+ // Delete all track files and collect actual artist folders from file paths
+ let deletedFiles = 0;
+ const artistFoldersToDelete = new Set();
+
+ for (const album of artist.albums) {
+ for (const track of album.tracks) {
+ if (track.filePath) {
+ try {
+ const absolutePath = path.join(
+ config.music.musicPath,
+ track.filePath
+ );
+
+ if (fs.existsSync(absolutePath)) {
+ fs.unlinkSync(absolutePath);
+ deletedFiles++;
+
+ // Extract actual artist folder from file path
+ // Path format: Soulseek/Artist/Album/Track.mp3 OR Artist/Album/Track.mp3
+ const pathParts = track.filePath.split(path.sep);
+ if (pathParts.length >= 2) {
+ // If first part is "Soulseek", artist folder is Soulseek/Artist
+ // Otherwise, artist folder is just Artist
+ const actualArtistFolder =
+ pathParts[0].toLowerCase() === "soulseek"
+ ? path.join(
+ config.music.musicPath,
+ pathParts[0],
+ pathParts[1]
+ )
+ : path.join(
+ config.music.musicPath,
+ pathParts[0]
+ );
+ artistFoldersToDelete.add(actualArtistFolder);
+ } else if (pathParts.length === 1) {
+ // Single-level path (rare case)
+ const actualArtistFolder = path.join(
+ config.music.musicPath,
+ pathParts[0]
+ );
+ artistFoldersToDelete.add(actualArtistFolder);
+ }
+ }
+ } catch (err) {
+ console.warn("[DELETE] Could not delete file:", err);
+ }
+ }
+ }
+ }
+
+ // Delete artist folders based on actual file paths, not database name
+ for (const artistFolder of artistFoldersToDelete) {
+ try {
+ if (fs.existsSync(artistFolder)) {
+ console.log(`[DELETE] Attempting to delete folder: ${artistFolder}`);
+
+ // Always try recursive delete with force
+ fs.rmSync(artistFolder, {
+ recursive: true,
+ force: true,
+ });
+ console.log(`[DELETE] Successfully deleted artist folder: ${artistFolder}`);
+ }
+ } catch (err: any) {
+ console.error(`[DELETE] Failed to delete artist folder ${artistFolder}:`, err?.message || err);
+
+ // Try alternative: delete contents first, then folder
+ try {
+ const files = fs.readdirSync(artistFolder);
+ for (const file of files) {
+ const filePath = path.join(artistFolder, file);
+ try {
+ const stat = fs.statSync(filePath);
+ if (stat.isDirectory()) {
+ fs.rmSync(filePath, { recursive: true, force: true });
+ } else {
+ fs.unlinkSync(filePath);
+ }
+ console.log(`[DELETE] Deleted: ${filePath}`);
+ } catch (fileErr: any) {
+ console.error(`[DELETE] Could not delete ${filePath}:`, fileErr?.message);
+ }
+ }
+ // Try deleting the now-empty folder
+ fs.rmdirSync(artistFolder);
+ console.log(`[DELETE] Deleted artist folder after manual cleanup: ${artistFolder}`);
+ } catch (cleanupErr: any) {
+ console.error(`[DELETE] Cleanup also failed for ${artistFolder}:`, cleanupErr?.message);
+ }
+ }
+ }
+
+ // Also try deleting from common music folder paths (in case tracks weren't indexed)
+ const commonPaths = [
+ path.join(config.music.musicPath, artist.name),
+ path.join(config.music.musicPath, "Soulseek", artist.name),
+ path.join(config.music.musicPath, "discovery", artist.name),
+ ];
+
+ for (const commonPath of commonPaths) {
+ if (fs.existsSync(commonPath) && !artistFoldersToDelete.has(commonPath)) {
+ try {
+ fs.rmSync(commonPath, { recursive: true, force: true });
+ console.log(`[DELETE] Deleted additional artist folder: ${commonPath}`);
+ } catch (err: any) {
+ console.error(`[DELETE] Could not delete ${commonPath}:`, err?.message);
+ }
+ }
+ }
+
+ // Delete from Lidarr if connected and artist has MBID
+ let lidarrDeleted = false;
+ let lidarrError: string | null = null;
+ if (artist.mbid && !artist.mbid.startsWith("temp-")) {
+ try {
+ const { lidarrService } = await import("../services/lidarr");
+ const lidarrResult = await lidarrService.deleteArtist(artist.mbid, true);
+ if (lidarrResult.success) {
+ console.log(`[DELETE] Lidarr: ${lidarrResult.message}`);
+ lidarrDeleted = true;
+ } else {
+ console.warn(`[DELETE] Lidarr deletion note: ${lidarrResult.message}`);
+ lidarrError = lidarrResult.message;
+ }
+ } catch (err: any) {
+ console.warn("[DELETE] Could not delete from Lidarr:", err?.message || err);
+ lidarrError = err?.message || "Unknown error";
+ }
+ }
+
+ // Explicitly delete OwnedAlbum records first (should cascade, but being safe)
+ try {
+ await prisma.ownedAlbum.deleteMany({
+ where: { artistId: artist.id },
+ });
+ } catch (err) {
+ console.warn("[DELETE] Could not delete OwnedAlbum records:", err);
+ }
+
+ // Delete from database (cascade will delete albums and tracks)
+ console.log(`[DELETE] Deleting artist from database: ${artist.name} (${artist.id})`);
+ await prisma.artist.delete({
+ where: { id: artist.id },
+ });
+
+ console.log(
+ `[DELETE] Successfully deleted artist: ${artist.name} (${deletedFiles} files${lidarrDeleted ? ', removed from Lidarr' : ''})`
+ );
+
+ res.json({
+ message: "Artist deleted successfully",
+ deletedFiles,
+ lidarrDeleted,
+ lidarrError,
+ });
+ } catch (error: any) {
+ console.error("Delete artist error:", error?.message || error);
+ console.error("Delete artist stack:", error?.stack);
+ res.status(500).json({
+ error: "Failed to delete artist",
+ details: error?.message || "Unknown error"
+ });
+ }
+});
+
+/**
+ * GET /library/genres
+ * Get list of genres in the library with track counts
+ */
+router.get("/genres", async (req, res) => {
+ try {
+ // Get artist names to filter them out of genres (they sometimes get incorrectly tagged)
+ const artists = await prisma.artist.findMany({
+ select: { name: true, normalizedName: true },
+ });
+ const artistNames = new Set(artists.flatMap(a => [
+ a.name.toLowerCase(),
+ a.normalizedName?.toLowerCase(),
+ ].filter(Boolean)));
+
+ // Get genres from TrackGenre relation (most accurate)
+ const trackGenres = await prisma.genre.findMany({
+ include: {
+ _count: {
+ select: { trackGenres: true },
+ },
+ },
+ });
+
+ const genreMap = new Map();
+
+ // Add track genre counts (excluding artist names)
+ for (const g of trackGenres) {
+ if (g.name && g._count.trackGenres > 0) {
+ const normalized = g.name.trim();
+ // Skip if it matches an artist name
+ if (normalized && !artistNames.has(normalized.toLowerCase())) {
+ genreMap.set(normalized, g._count.trackGenres);
+ }
+ }
+ }
+
+ // Fallback: Get genres from Album.genres JSON field if no TrackGenres
+ if (genreMap.size === 0) {
+ const albums = await prisma.album.findMany({
+ where: {
+ genres: { not: null },
+ },
+ select: {
+ genres: true,
+ _count: { select: { tracks: true } },
+ },
+ });
+
+ for (const album of albums) {
+ const albumGenres = album.genres as string[] | null;
+ if (albumGenres && Array.isArray(albumGenres)) {
+ for (const genre of albumGenres) {
+ const normalized = genre.trim();
+ // Skip if it matches an artist name
+ if (normalized && !artistNames.has(normalized.toLowerCase())) {
+ genreMap.set(
+ normalized,
+ (genreMap.get(normalized) || 0) + album._count.tracks
+ );
+ }
+ }
+ }
+ }
+ }
+
+ // Convert to array and sort by count
+ const genres = Array.from(genreMap.entries())
+ .map(([genre, count]) => ({ genre, count }))
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 20); // Top 20 genres
+
+ res.json({ genres });
+ } catch (error) {
+ console.error("Genres endpoint error:", error);
+ res.status(500).json({ error: "Failed to get genres" });
+ }
+});
+
+/**
+ * GET /library/decades
+ * Get available decades in the library with track counts
+ * Returns only decades with enough tracks (15+)
+ */
+router.get("/decades", async (req, res) => {
+ try {
+ // Get all albums with year and track count
+ const albums = await prisma.album.findMany({
+ where: {
+ year: { not: null },
+ },
+ select: {
+ year: true,
+ _count: { select: { tracks: true } },
+ },
+ });
+
+ // Group by decade
+ const decadeMap = new Map();
+
+ for (const album of albums) {
+ if (album.year) {
+ // Calculate decade start (e.g., 1987 -> 1980, 2023 -> 2020)
+ const decadeStart = Math.floor(album.year / 10) * 10;
+ decadeMap.set(
+ decadeStart,
+ (decadeMap.get(decadeStart) || 0) + album._count.tracks
+ );
+ }
+ }
+
+ // Convert to array, filter by minimum tracks, and sort by decade
+ const decades = Array.from(decadeMap.entries())
+ .map(([decade, count]) => ({ decade, count }))
+ .filter(d => d.count >= 15) // Minimum 15 tracks for a radio station
+ .sort((a, b) => b.decade - a.decade); // Newest first
+
+ res.json({ decades });
+ } catch (error) {
+ console.error("Decades endpoint error:", error);
+ res.status(500).json({ error: "Failed to get decades" });
+ }
+});
+
+/**
+ * GET /library/radio
+ * Get tracks for a library-based radio station
+ *
+ * Query params:
+ * - type: "discovery" | "favorites" | "decade" | "genre" | "mood"
+ * - value: Optional value for decade (e.g., "1990") or genre name
+ * - limit: Number of tracks to return (default 50)
+ */
+router.get("/radio", async (req, res) => {
+ try {
+ const { type, value, limit = "50" } = req.query;
+ const limitNum = Math.min(parseInt(limit as string) || 50, 100);
+ const userId = req.user?.id;
+
+ if (!type) {
+ return res.status(400).json({ error: "Radio type is required" });
+ }
+
+ let whereClause: any = {};
+ let orderBy: any = {};
+ let trackIds: string[] = [];
+ let vibeSourceFeatures: any = null; // For vibe mode - store source track features
+
+ switch (type) {
+ case "discovery":
+ // Lesser-played tracks - get tracks the user hasn't played or played least
+ // First, get tracks with NO plays at all (truly undiscovered)
+ const unplayedTracks = await prisma.track.findMany({
+ where: {
+ plays: { none: {} }, // No plays by anyone
+ },
+ select: { id: true },
+ take: limitNum * 2,
+ });
+
+ if (unplayedTracks.length >= limitNum) {
+ trackIds = unplayedTracks.map(t => t.id);
+ } else {
+ // Fallback: get tracks with the fewest plays using raw count
+ const leastPlayedTracks = await prisma.$queryRaw<{ id: string }[]>`
+ SELECT t.id
+ FROM "Track" t
+ LEFT JOIN "Play" p ON p."trackId" = t.id
+ GROUP BY t.id
+ ORDER BY COUNT(p.id) ASC
+ LIMIT ${limitNum * 2}
+ `;
+ trackIds = leastPlayedTracks.map(t => t.id);
+ }
+ break;
+
+ case "favorites":
+ // Most-played tracks - use raw query for accurate count ordering
+ const mostPlayedTracks = await prisma.$queryRaw<{ id: string; play_count: bigint }[]>`
+ SELECT t.id, COUNT(p.id) as play_count
+ FROM "Track" t
+ LEFT JOIN "Play" p ON p."trackId" = t.id
+ GROUP BY t.id
+ HAVING COUNT(p.id) > 0
+ ORDER BY play_count DESC
+ LIMIT ${limitNum * 2}
+ `;
+
+ if (mostPlayedTracks.length > 0) {
+ trackIds = mostPlayedTracks.map(t => t.id);
+ } else {
+ // No play data yet - just get random tracks
+ console.log("[Radio:favorites] No play data found, returning random tracks");
+ const randomTracks = await prisma.track.findMany({
+ select: { id: true },
+ take: limitNum * 2,
+ });
+ trackIds = randomTracks.map(t => t.id);
+ }
+ break;
+
+ case "decade":
+ // Filter by decade (e.g., value = "1990" for 90s)
+ const decadeStart = parseInt(value as string) || 2000;
+ const decadeEnd = decadeStart + 9;
+
+ const decadeTracks = await prisma.track.findMany({
+ where: {
+ album: {
+ year: {
+ gte: decadeStart,
+ lte: decadeEnd,
+ },
+ },
+ },
+ select: { id: true },
+ take: limitNum * 3,
+ });
+ trackIds = decadeTracks.map(t => t.id);
+ break;
+
+ case "genre":
+ // Filter by genre (matches against album or track genre tags)
+ const genreValue = (value as string || "").toLowerCase();
+
+ // Strategy 1: Check trackGenres relation (most reliable)
+ const genreRelationTracks = await prisma.track.findMany({
+ where: {
+ trackGenres: {
+ some: {
+ genre: {
+ name: { contains: genreValue, mode: "insensitive" }
+ }
+ }
+ },
+ },
+ select: { id: true },
+ take: limitNum * 2,
+ });
+ trackIds = genreRelationTracks.map(t => t.id);
+
+ // Strategy 2: If not enough, check album.genres JSON field with raw query
+ if (trackIds.length < limitNum) {
+ const albumGenreTracks = await prisma.$queryRaw<{ id: string }[]>`
+ SELECT t.id
+ FROM "Track" t
+ JOIN "Album" a ON t."albumId" = a.id
+ WHERE a.genres IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM jsonb_array_elements_text(a.genres::jsonb) AS g
+ WHERE LOWER(g) LIKE ${'%' + genreValue + '%'}
+ )
+ LIMIT ${limitNum * 2}
+ `;
+ const newIds = albumGenreTracks.map(t => t.id).filter(id => !trackIds.includes(id));
+ trackIds = [...trackIds, ...newIds];
+ }
+
+ console.log(`[Radio:genre] Found ${trackIds.length} tracks for genre "${genreValue}"`);
+ break;
+
+ case "mood":
+ // Mood-based filtering using audio analysis features
+ const moodValue = (value as string || "").toLowerCase();
+ let moodWhere: any = { analysisStatus: "completed" };
+
+ switch (moodValue) {
+ case "high-energy":
+ moodWhere = {
+ analysisStatus: "completed",
+ energy: { gte: 0.7 },
+ bpm: { gte: 120 },
+ };
+ break;
+ case "chill":
+ moodWhere = {
+ analysisStatus: "completed",
+ OR: [
+ { energy: { lte: 0.4 } },
+ { arousal: { lte: 0.4 } },
+ ],
+ };
+ break;
+ case "happy":
+ moodWhere = {
+ analysisStatus: "completed",
+ valence: { gte: 0.6 },
+ energy: { gte: 0.5 },
+ };
+ break;
+ case "melancholy":
+ moodWhere = {
+ analysisStatus: "completed",
+ OR: [
+ { valence: { lte: 0.4 } },
+ { keyScale: "minor" },
+ ],
+ };
+ break;
+ case "dance":
+ moodWhere = {
+ analysisStatus: "completed",
+ danceability: { gte: 0.7 },
+ };
+ break;
+ case "acoustic":
+ moodWhere = {
+ analysisStatus: "completed",
+ acousticness: { gte: 0.6 },
+ };
+ break;
+ case "instrumental":
+ moodWhere = {
+ analysisStatus: "completed",
+ instrumentalness: { gte: 0.7 },
+ };
+ break;
+ default:
+ // Try Last.fm tags if mood not recognized
+ moodWhere = {
+ lastfmTags: { has: moodValue },
+ };
+ }
+
+ const moodTracks = await prisma.track.findMany({
+ where: moodWhere,
+ select: { id: true },
+ take: limitNum * 3,
+ });
+ trackIds = moodTracks.map(t => t.id);
+ break;
+
+ case "workout":
+ // High-energy workout tracks - multiple strategies
+ let workoutTrackIds: string[] = [];
+
+ // Strategy 1: Audio analysis - high energy AND fast BPM
+ const energyTracks = await prisma.track.findMany({
+ where: {
+ analysisStatus: "completed",
+ OR: [
+ // High energy with fast tempo
+ { AND: [{ energy: { gte: 0.65 } }, { bpm: { gte: 115 } }] },
+ // Has workout mood tag
+ { moodTags: { hasSome: ["workout", "energetic", "upbeat"] } },
+ ],
+ },
+ select: { id: true },
+ take: limitNum * 2,
+ });
+ workoutTrackIds = energyTracks.map(t => t.id);
+ console.log(`[Radio:workout] Found ${workoutTrackIds.length} tracks via audio analysis`);
+
+ // Strategy 2: Genre-based (if not enough from audio)
+ if (workoutTrackIds.length < limitNum) {
+ const workoutGenreNames = [
+ "rock", "metal", "hard rock", "alternative rock", "punk",
+ "hip hop", "rap", "trap", "electronic", "edm", "house",
+ "techno", "drum and bass", "dubstep", "hardstyle",
+ "metalcore", "hardcore", "industrial", "nu metal", "pop punk"
+ ];
+
+ // Check Genre table
+ const workoutGenres = await prisma.genre.findMany({
+ where: {
+ name: { in: workoutGenreNames, mode: "insensitive" },
+ },
+ include: {
+ trackGenres: { select: { trackId: true }, take: 50 },
+ },
+ });
+
+ const genreTrackIds = workoutGenres.flatMap(g => g.trackGenres.map(tg => tg.trackId));
+ workoutTrackIds = [...new Set([...workoutTrackIds, ...genreTrackIds])];
+ console.log(`[Radio:workout] After genre check: ${workoutTrackIds.length} tracks`);
+
+ // Also check album.genres JSON field
+ if (workoutTrackIds.length < limitNum) {
+ const albumGenreTracks = await prisma.track.findMany({
+ where: {
+ album: {
+ OR: workoutGenreNames.map(g => ({
+ genres: { string_contains: g },
+ })),
+ },
+ },
+ select: { id: true },
+ take: limitNum,
+ });
+ workoutTrackIds = [...new Set([...workoutTrackIds, ...albumGenreTracks.map(t => t.id)])];
+ console.log(`[Radio:workout] After album genre check: ${workoutTrackIds.length} tracks`);
+ }
+ }
+
+ trackIds = workoutTrackIds;
+ break;
+
+ case "artist":
+ // Artist Radio - plays tracks from the artist + similar artists in library
+ // Uses hybrid approach: Last.fm similarity (filtered to library) + genre matching + vibe boost
+ const artistId = value as string;
+ if (!artistId) {
+ return res.status(400).json({ error: "Artist ID required for artist radio" });
+ }
+
+ console.log(`[Radio:artist] Starting artist radio for: ${artistId}`);
+
+ // 1. Get tracks from this artist (they're in library by definition)
+ const artistTracks = await prisma.track.findMany({
+ where: { album: { artistId } },
+ select: {
+ id: true,
+ bpm: true,
+ energy: true,
+ valence: true,
+ danceability: true,
+ },
+ });
+ console.log(`[Radio:artist] Found ${artistTracks.length} tracks from artist`);
+
+ if (artistTracks.length === 0) {
+ return res.json({ tracks: [] });
+ }
+
+ // Calculate artist's average "vibe" for later matching
+ const analyzedTracks = artistTracks.filter(t => t.bpm || t.energy || t.valence);
+ const avgVibe = analyzedTracks.length > 0 ? {
+ bpm: analyzedTracks.reduce((sum, t) => sum + (t.bpm || 0), 0) / analyzedTracks.length,
+ energy: analyzedTracks.reduce((sum, t) => sum + (t.energy || 0), 0) / analyzedTracks.length,
+ valence: analyzedTracks.reduce((sum, t) => sum + (t.valence || 0), 0) / analyzedTracks.length,
+ danceability: analyzedTracks.reduce((sum, t) => sum + (t.danceability || 0), 0) / analyzedTracks.length,
+ } : null;
+ console.log(`[Radio:artist] Artist vibe:`, avgVibe);
+
+ // 2. Get library artist IDs (artists user actually owns)
+ const ownedArtists = await prisma.ownedAlbum.findMany({
+ select: { artistId: true },
+ distinct: ['artistId'],
+ });
+ const libraryArtistIds = new Set(ownedArtists.map(o => o.artistId));
+ libraryArtistIds.delete(artistId); // Exclude the current artist
+ console.log(`[Radio:artist] Library has ${libraryArtistIds.size} other artists`);
+
+ // 3. Try Last.fm similar artists, filtered to library
+ const similarInLibrary = await prisma.similarArtist.findMany({
+ where: {
+ fromArtistId: artistId,
+ toArtistId: { in: Array.from(libraryArtistIds) },
+ },
+ orderBy: { weight: "desc" },
+ take: 15,
+ });
+ let similarArtistIds = similarInLibrary.map(s => s.toArtistId);
+ console.log(`[Radio:artist] Found ${similarArtistIds.length} Last.fm similar artists in library`);
+
+ // 4. Fallback: genre matching if not enough similar artists
+ if (similarArtistIds.length < 5 && libraryArtistIds.size > 0) {
+ const artist = await prisma.artist.findUnique({
+ where: { id: artistId },
+ select: { genres: true },
+ });
+ const artistGenres = (artist?.genres as string[]) || [];
+
+ if (artistGenres.length > 0) {
+ // Find library artists with overlapping genres
+ const genreMatchArtists = await prisma.artist.findMany({
+ where: {
+ id: { in: Array.from(libraryArtistIds) },
+ },
+ select: { id: true, genres: true },
+ });
+
+ // Score artists by genre overlap
+ const scoredArtists = genreMatchArtists
+ .map(a => {
+ const theirGenres = (a.genres as string[]) || [];
+ const overlap = artistGenres.filter(g =>
+ theirGenres.some(tg => tg.toLowerCase().includes(g.toLowerCase()) ||
+ g.toLowerCase().includes(tg.toLowerCase()))
+ ).length;
+ return { id: a.id, score: overlap };
+ })
+ .filter(a => a.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .slice(0, 10);
+
+ const genreArtistIds = scoredArtists.map(a => a.id);
+ similarArtistIds = [...new Set([...similarArtistIds, ...genreArtistIds])];
+ console.log(`[Radio:artist] After genre matching: ${similarArtistIds.length} similar artists`);
+ }
+ }
+
+ // 5. Get tracks from similar library artists
+ let similarTracks: { id: string; bpm: number | null; energy: number | null; valence: number | null; danceability: number | null }[] = [];
+ if (similarArtistIds.length > 0) {
+ similarTracks = await prisma.track.findMany({
+ where: { album: { artistId: { in: similarArtistIds } } },
+ select: {
+ id: true,
+ bpm: true,
+ energy: true,
+ valence: true,
+ danceability: true,
+ },
+ });
+ console.log(`[Radio:artist] Found ${similarTracks.length} tracks from similar artists`);
+ }
+
+ // 6. Apply vibe boost if we have audio analysis data
+ if (avgVibe && similarTracks.length > 0) {
+ // Score each similar track by how close its vibe is to the artist's average
+ similarTracks = similarTracks
+ .map(t => {
+ if (!t.bpm && !t.energy && !t.valence) return { ...t, vibeScore: 0.5 };
+
+ let score = 0;
+ let factors = 0;
+
+ if (t.bpm && avgVibe.bpm) {
+ // BPM within 20 = good match
+ const bpmDiff = Math.abs(t.bpm - avgVibe.bpm);
+ score += Math.max(0, 1 - bpmDiff / 40);
+ factors++;
+ }
+ if (t.energy !== null && avgVibe.energy) {
+ score += 1 - Math.abs((t.energy || 0) - avgVibe.energy);
+ factors++;
+ }
+ if (t.valence !== null && avgVibe.valence) {
+ score += 1 - Math.abs((t.valence || 0) - avgVibe.valence);
+ factors++;
+ }
+ if (t.danceability !== null && avgVibe.danceability) {
+ score += 1 - Math.abs((t.danceability || 0) - avgVibe.danceability);
+ factors++;
+ }
+
+ return { ...t, vibeScore: factors > 0 ? score / factors : 0.5 };
+ })
+ .sort((a, b) => (b as any).vibeScore - (a as any).vibeScore);
+
+ console.log(`[Radio:artist] Applied vibe boost, top score: ${(similarTracks[0] as any)?.vibeScore?.toFixed(2)}`);
+ }
+
+ // 7. Mix: ~40% original artist, ~60% similar (vibe-boosted)
+ const originalCount = Math.min(Math.ceil(limitNum * 0.4), artistTracks.length);
+ const similarCount = Math.min(limitNum - originalCount, similarTracks.length);
+
+ const selectedOriginal = artistTracks.sort(() => Math.random() - 0.5).slice(0, originalCount);
+ // Take top vibe-matched tracks (already sorted by vibe score), then shuffle slightly
+ const selectedSimilar = similarTracks.slice(0, similarCount * 2)
+ .sort(() => Math.random() - 0.3) // Slight shuffle to add variety
+ .slice(0, similarCount);
+
+ trackIds = [...selectedOriginal, ...selectedSimilar].map(t => t.id);
+ console.log(`[Radio:artist] Final mix: ${selectedOriginal.length} original + ${selectedSimilar.length} similar = ${trackIds.length} tracks`);
+ break;
+
+ case "vibe":
+ // Vibe Match - finds tracks that sound like the given track
+ // Pure audio feature matching with graceful fallbacks
+ const sourceTrackId = value as string;
+ if (!sourceTrackId) {
+ return res.status(400).json({ error: "Track ID required for vibe matching" });
+ }
+
+ console.log(`[Radio:vibe] Starting vibe match for track: ${sourceTrackId}`);
+
+ // 1. Get the source track's audio features (including Enhanced mode fields)
+ const sourceTrack = await prisma.track.findUnique({
+ where: { id: sourceTrackId },
+ include: {
+ album: {
+ select: {
+ artistId: true,
+ genres: true,
+ artist: { select: { id: true, name: true } }
+ }
+ }
+ }
+ }) as any; // Cast to any to include all Track fields
+
+ if (!sourceTrack) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Check if track has Enhanced mode analysis
+ const isEnhancedAnalysis = sourceTrack.analysisMode === 'enhanced' ||
+ (sourceTrack.moodHappy !== null && sourceTrack.moodSad !== null);
+
+ console.log(`[Radio:vibe] Source: "${sourceTrack.title}" by ${sourceTrack.album.artist.name}`);
+ console.log(`[Radio:vibe] Analysis mode: ${isEnhancedAnalysis ? 'ENHANCED' : 'STANDARD'}`);
+ console.log(`[Radio:vibe] Source features: BPM=${sourceTrack.bpm}, Energy=${sourceTrack.energy}, Valence=${sourceTrack.valence}`);
+ if (isEnhancedAnalysis) {
+ console.log(`[Radio:vibe] ML Moods: Happy=${sourceTrack.moodHappy}, Sad=${sourceTrack.moodSad}, Relaxed=${sourceTrack.moodRelaxed}, Aggressive=${sourceTrack.moodAggressive}, Party=${sourceTrack.moodParty}, Acoustic=${sourceTrack.moodAcoustic}, Electronic=${sourceTrack.moodElectronic}`);
+ }
+
+ // Store source features for frontend visualization
+ vibeSourceFeatures = {
+ bpm: sourceTrack.bpm,
+ energy: sourceTrack.energy,
+ valence: sourceTrack.valence,
+ arousal: sourceTrack.arousal,
+ danceability: sourceTrack.danceability,
+ keyScale: sourceTrack.keyScale,
+ instrumentalness: sourceTrack.instrumentalness,
+ // Enhanced mode features (all 7 ML mood predictions)
+ moodHappy: sourceTrack.moodHappy,
+ moodSad: sourceTrack.moodSad,
+ moodRelaxed: sourceTrack.moodRelaxed,
+ moodAggressive: sourceTrack.moodAggressive,
+ moodParty: sourceTrack.moodParty,
+ moodAcoustic: sourceTrack.moodAcoustic,
+ moodElectronic: sourceTrack.moodElectronic,
+ analysisMode: isEnhancedAnalysis ? 'enhanced' : 'standard',
+ };
+
+ let vibeMatchedIds: string[] = [];
+ const sourceArtistId = sourceTrack.album.artistId;
+
+ // 2. Try audio feature matching first (if track is analyzed)
+ const hasAudioData = sourceTrack.bpm || sourceTrack.energy || sourceTrack.valence;
+
+ if (hasAudioData) {
+ // Get all analyzed tracks (excluding source) - include Enhanced mode fields
+ const analyzedTracks = await prisma.track.findMany({
+ where: {
+ id: { not: sourceTrackId },
+ analysisStatus: "completed",
+ },
+ select: {
+ id: true,
+ bpm: true,
+ energy: true,
+ valence: true,
+ arousal: true,
+ danceability: true,
+ keyScale: true,
+ moodTags: true,
+ lastfmTags: true,
+ essentiaGenres: true,
+ instrumentalness: true,
+ // Enhanced mode fields (all 7 ML mood predictions)
+ moodHappy: true,
+ moodSad: true,
+ moodRelaxed: true,
+ moodAggressive: true,
+ moodParty: true,
+ moodAcoustic: true,
+ moodElectronic: true,
+ danceabilityMl: true,
+ analysisMode: true,
+ },
+ });
+
+ console.log(`[Radio:vibe] Found ${analyzedTracks.length} analyzed tracks to compare`);
+
+ if (analyzedTracks.length > 0) {
+ // === COSINE SIMILARITY SCORING ===
+ // Industry-standard approach: build feature vectors, compute cosine similarity
+ // Uses ALL 13 features for comprehensive matching
+
+ // Enhanced valence: mode/tonality + mood + audio features
+ const calculateEnhancedValence = (track: any): number => {
+ const happy = track.moodHappy ?? 0.5;
+ const sad = track.moodSad ?? 0.5;
+ const party = (track as any).moodParty ?? 0.5;
+ const isMajor = track.keyScale === 'major';
+ const isMinor = track.keyScale === 'minor';
+ const modeValence = isMajor ? 0.3 : (isMinor ? -0.2 : 0);
+ const moodValence = happy * 0.35 + party * 0.25 + (1 - sad) * 0.2;
+ const audioValence = (track.energy ?? 0.5) * 0.1 + (track.danceabilityMl ?? track.danceability ?? 0.5) * 0.1;
+
+ return Math.max(0, Math.min(1, moodValence + modeValence + audioValence));
+ };
+
+ // Enhanced arousal: mood + energy + tempo (avoids unreliable "electronic" mood)
+ const calculateEnhancedArousal = (track: any): number => {
+ const aggressive = track.moodAggressive ?? 0.5;
+ const party = (track as any).moodParty ?? 0.5;
+ const relaxed = track.moodRelaxed ?? 0.5;
+ const acoustic = (track as any).moodAcoustic ?? 0.5;
+ const energy = track.energy ?? 0.5;
+ const bpm = track.bpm ?? 120;
+ const moodArousal = aggressive * 0.3 + party * 0.2;
+ const energyArousal = energy * 0.25;
+ const tempoArousal = Math.max(0, Math.min(1, (bpm - 60) / 120)) * 0.15;
+ const calmReduction = ((1 - relaxed) * 0.05) + ((1 - acoustic) * 0.05);
+
+ return Math.max(0, Math.min(1, moodArousal + energyArousal + tempoArousal + calmReduction));
+ };
+
+ // OOD detection using Energy-based scoring
+ const detectOOD = (track: any): boolean => {
+ const coreMoods = [
+ track.moodHappy ?? 0.5,
+ track.moodSad ?? 0.5,
+ track.moodRelaxed ?? 0.5,
+ track.moodAggressive ?? 0.5
+ ];
+
+ const minMood = Math.min(...coreMoods);
+ const maxMood = Math.max(...coreMoods);
+
+ // Enhanced OOD detection based on research
+ // Flag if all core moods are high (>0.7) with low variance, OR if all are very neutral (~0.5)
+ const allHigh = minMood > 0.7 && (maxMood - minMood) < 0.3;
+ const allNeutral = Math.abs(maxMood - 0.5) < 0.15 && Math.abs(minMood - 0.5) < 0.15;
+
+ return allHigh || allNeutral;
+ };
+
+ // Octave-aware BPM distance calculation
+ const octaveAwareBPMDistance = (bpm1: number, bpm2: number): number => {
+ if (!bpm1 || !bpm2) return 0;
+
+ // Normalize to standard octave range (77-154 BPM)
+ const normalizeToOctave = (bpm: number): number => {
+ while (bpm < 77) bpm *= 2;
+ while (bpm > 154) bpm /= 2;
+ return bpm;
+ };
+
+ const norm1 = normalizeToOctave(bpm1);
+ const norm2 = normalizeToOctave(bpm2);
+
+ // Calculate distance on logarithmic scale for harmonic equivalence
+ const logDistance = Math.abs(Math.log2(norm1) - Math.log2(norm2));
+ return Math.min(logDistance, 1); // Cap at 1 for similarity calculation
+ };
+
+ // Helper: Build enhanced weighted feature vector from track
+ const buildFeatureVector = (track: any): number[] => {
+ // Detect OOD and apply normalization if needed
+ const isOOD = detectOOD(track);
+
+ // Get mood values with OOD normalization
+ const getMoodValue = (value: number | null, defaultValue: number): number => {
+ if (!value) return defaultValue;
+ if (!isOOD) return value;
+ // Normalize OOD predictions to spread them out (0.2-0.8 range)
+ return 0.2 + Math.max(0, Math.min(0.6, value - 0.2));
+ };
+
+ // Use enhanced valence/arousal calculations
+ const enhancedValence = calculateEnhancedValence(track);
+ const enhancedArousal = calculateEnhancedArousal(track);
+
+ return [
+ // ML Mood predictions (7 features) - enhanced weighting and OOD handling
+ getMoodValue(track.moodHappy, 0.5) * 1.3, // 1.3x weight for semantic features
+ getMoodValue(track.moodSad, 0.5) * 1.3,
+ getMoodValue(track.moodRelaxed, 0.5) * 1.3,
+ getMoodValue(track.moodAggressive, 0.5) * 1.3,
+ getMoodValue((track as any).moodParty, 0.5) * 1.3,
+ getMoodValue((track as any).moodAcoustic, 0.5) * 1.3,
+ getMoodValue((track as any).moodElectronic, 0.5) * 1.3,
+ // Audio features (5 features) - standard weight
+ track.energy ?? 0.5,
+ enhancedArousal, // Use enhanced arousal
+ track.danceabilityMl ?? track.danceability ?? 0.5,
+ track.instrumentalness ?? 0.5,
+ // Octave-aware BPM normalized to 0-1
+ 1 - octaveAwareBPMDistance(track.bpm ?? 120, 120), // Similarity to reference tempo
+ // Enhanced key mode with valence consideration
+ enhancedValence, // Use enhanced valence instead of binary key
+ ];
+ };
+
+ // Helper: Compute cosine similarity between two vectors
+ const cosineSimilarity = (a: number[], b: number[]): number => {
+ let dot = 0, magA = 0, magB = 0;
+ for (let i = 0; i < a.length; i++) {
+ dot += a[i] * b[i];
+ magA += a[i] * a[i];
+ magB += b[i] * b[i];
+ }
+ if (magA === 0 || magB === 0) return 0;
+ return dot / (Math.sqrt(magA) * Math.sqrt(magB));
+ };
+
+ // Helper: Compute tag overlap bonus
+ const computeTagBonus = (
+ sourceTags: string[],
+ sourceGenres: string[],
+ trackTags: string[],
+ trackGenres: string[]
+ ): number => {
+ const sourceSet = new Set([...sourceTags, ...sourceGenres].map(t => t.toLowerCase()));
+ const trackSet = new Set([...trackTags, ...trackGenres].map(t => t.toLowerCase()));
+ if (sourceSet.size === 0 || trackSet.size === 0) return 0;
+ const overlap = [...sourceSet].filter(tag => trackSet.has(tag)).length;
+ // Max 5% bonus for tag overlap
+ return Math.min(0.05, overlap * 0.01);
+ };
+
+ // Build source feature vector once
+ const sourceVector = buildFeatureVector(sourceTrack);
+
+ // Check if source track has Enhanced mode data
+ const bothEnhanced = isEnhancedAnalysis;
+
+ const scored = analyzedTracks.map(t => {
+ // Check if target track has Enhanced mode data
+ const targetEnhanced = t.analysisMode === 'enhanced' ||
+ (t.moodHappy !== null && t.moodSad !== null);
+ const useEnhanced = bothEnhanced && targetEnhanced;
+
+ // Build target feature vector
+ const targetVector = buildFeatureVector(t as any);
+
+ // Compute base cosine similarity
+ let score = cosineSimilarity(sourceVector, targetVector);
+
+ // Add tag/genre overlap bonus (max 5%)
+ const tagBonus = computeTagBonus(
+ sourceTrack.lastfmTags || [],
+ sourceTrack.essentiaGenres || [],
+ t.lastfmTags || [],
+ t.essentiaGenres || []
+ );
+
+ // Final score: 95% cosine similarity + 5% tag bonus
+ const finalScore = score * 0.95 + tagBonus;
+
+ return { id: t.id, score: finalScore, enhanced: useEnhanced };
+ });
+
+ // Filter to good matches and sort by score
+ // Use lower threshold (40%) for Enhanced mode since it's more precise
+ const minThreshold = isEnhancedAnalysis ? 0.40 : 0.50;
+ const goodMatches = scored
+ .filter(t => t.score > minThreshold)
+ .sort((a, b) => b.score - a.score);
+
+ vibeMatchedIds = goodMatches.map(t => t.id);
+ const enhancedCount = goodMatches.filter(t => t.enhanced).length;
+ console.log(`[Radio:vibe] Audio matching found ${vibeMatchedIds.length} tracks (>${minThreshold * 100}% similarity)`);
+ console.log(`[Radio:vibe] Enhanced matches: ${enhancedCount}, Standard matches: ${goodMatches.length - enhancedCount}`);
+
+ if (goodMatches.length > 0) {
+ console.log(`[Radio:vibe] Top match score: ${goodMatches[0].score.toFixed(2)} (${goodMatches[0].enhanced ? 'enhanced' : 'standard'})`);
+ }
+ }
+ }
+
+ // 3. Fallback A: Same artist's other tracks
+ if (vibeMatchedIds.length < limitNum) {
+ const artistTracks = await prisma.track.findMany({
+ where: {
+ album: { artistId: sourceArtistId },
+ id: { notIn: [sourceTrackId, ...vibeMatchedIds] },
+ },
+ select: { id: true },
+ });
+ const newIds = artistTracks.map(t => t.id);
+ vibeMatchedIds = [...vibeMatchedIds, ...newIds];
+ console.log(`[Radio:vibe] Fallback A (same artist): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`);
+ }
+
+ // 4. Fallback B: Similar artists from Last.fm (filtered to library)
+ if (vibeMatchedIds.length < limitNum) {
+ const ownedArtistIds = await prisma.ownedAlbum.findMany({
+ select: { artistId: true },
+ distinct: ['artistId'],
+ });
+ const libraryArtistSet = new Set(ownedArtistIds.map(o => o.artistId));
+ libraryArtistSet.delete(sourceArtistId);
+
+ const similarArtists = await prisma.similarArtist.findMany({
+ where: {
+ fromArtistId: sourceArtistId,
+ toArtistId: { in: Array.from(libraryArtistSet) },
+ },
+ orderBy: { weight: "desc" },
+ take: 10,
+ });
+
+ if (similarArtists.length > 0) {
+ const similarArtistTracks = await prisma.track.findMany({
+ where: {
+ album: { artistId: { in: similarArtists.map(s => s.toArtistId) } },
+ id: { notIn: [sourceTrackId, ...vibeMatchedIds] },
+ },
+ select: { id: true },
+ });
+ const newIds = similarArtistTracks.map(t => t.id);
+ vibeMatchedIds = [...vibeMatchedIds, ...newIds];
+ console.log(`[Radio:vibe] Fallback B (similar artists): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`);
+ }
+ }
+
+ // 5. Fallback C: Same genre (using TrackGenre relation)
+ const sourceGenres = (sourceTrack.album.genres as string[]) || [];
+ if (vibeMatchedIds.length < limitNum && sourceGenres.length > 0) {
+ // Search using the TrackGenre relation for better accuracy
+ const genreTracks = await prisma.track.findMany({
+ where: {
+ trackGenres: {
+ some: {
+ genre: {
+ name: { in: sourceGenres, mode: "insensitive" },
+ },
+ },
+ },
+ id: { notIn: [sourceTrackId, ...vibeMatchedIds] },
+ },
+ select: { id: true },
+ take: limitNum,
+ });
+ const newIds = genreTracks.map(t => t.id);
+ vibeMatchedIds = [...vibeMatchedIds, ...newIds];
+ console.log(`[Radio:vibe] Fallback C (same genre): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`);
+ }
+
+ // 6. Fallback D: Random from library
+ if (vibeMatchedIds.length < limitNum) {
+ const randomTracks = await prisma.track.findMany({
+ where: { id: { notIn: [sourceTrackId, ...vibeMatchedIds] } },
+ select: { id: true },
+ take: limitNum - vibeMatchedIds.length,
+ });
+ const newIds = randomTracks.map(t => t.id);
+ vibeMatchedIds = [...vibeMatchedIds, ...newIds];
+ console.log(`[Radio:vibe] Fallback D (random): added ${newIds.length} tracks, total: ${vibeMatchedIds.length}`);
+ }
+
+ trackIds = vibeMatchedIds;
+ console.log(`[Radio:vibe] Final vibe queue: ${trackIds.length} tracks`);
+ break;
+
+ case "all":
+ default:
+ // Random selection from all tracks in library
+ const allTracks = await prisma.track.findMany({
+ select: { id: true },
+ });
+ trackIds = allTracks.map(t => t.id);
+ }
+
+ // For vibe mode, keep the sorted order (by match score)
+ // For other modes, shuffle the results
+ const finalIds = type === "vibe"
+ ? trackIds.slice(0, limitNum) // Already sorted by match score
+ : trackIds.sort(() => Math.random() - 0.5).slice(0, limitNum);
+
+ if (finalIds.length === 0) {
+ return res.json({ tracks: [] });
+ }
+
+ // Fetch full track data (include all analysis fields for logging)
+ const tracks = await prisma.track.findMany({
+ where: {
+ id: { in: finalIds },
+ },
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ },
+ trackGenres: {
+ include: {
+ genre: { select: { name: true } },
+ },
+ },
+ },
+ });
+
+ // For vibe mode, reorder tracks to match the sorted finalIds order
+ // (Prisma's findMany with IN doesn't preserve order)
+ let orderedTracks = tracks;
+ if (type === "vibe") {
+ const trackMap = new Map(tracks.map(t => [t.id, t]));
+ orderedTracks = finalIds
+ .map(id => trackMap.get(id))
+ .filter((t): t is typeof tracks[0] => t !== undefined);
+ }
+
+ // === VIBE QUEUE LOGGING ===
+ // Log detailed info for vibe matching analysis (using ordered tracks)
+ if (type === "vibe" && vibeSourceFeatures) {
+ console.log("\n" + "=".repeat(100));
+ console.log("VIBE QUEUE ANALYSIS - Source Track");
+ console.log("=".repeat(100));
+
+ // Find source track for logging
+ const srcTrack = await prisma.track.findUnique({
+ where: { id: value as string },
+ include: {
+ album: { include: { artist: { select: { name: true } } } },
+ trackGenres: { include: { genre: { select: { name: true } } } },
+ },
+ });
+
+ if (srcTrack) {
+ console.log(`SOURCE: "${srcTrack.title}" by ${srcTrack.album.artist.name}`);
+ console.log(` Album: ${srcTrack.album.title}`);
+ console.log(` Analysis Mode: ${(srcTrack as any).analysisMode || 'unknown'}`);
+ console.log(` BPM: ${srcTrack.bpm?.toFixed(1) || 'N/A'} | Energy: ${srcTrack.energy?.toFixed(2) || 'N/A'} | Valence: ${srcTrack.valence?.toFixed(2) || 'N/A'}`);
+ console.log(` Danceability: ${srcTrack.danceability?.toFixed(2) || 'N/A'} | Arousal: ${srcTrack.arousal?.toFixed(2) || 'N/A'} | Key: ${srcTrack.keyScale || 'N/A'}`);
+ console.log(` ML Moods: Happy=${(srcTrack as any).moodHappy?.toFixed(2) || 'N/A'}, Sad=${(srcTrack as any).moodSad?.toFixed(2) || 'N/A'}, Relaxed=${(srcTrack as any).moodRelaxed?.toFixed(2) || 'N/A'}, Aggressive=${(srcTrack as any).moodAggressive?.toFixed(2) || 'N/A'}`);
+ console.log(` Genres: ${srcTrack.trackGenres.map(tg => tg.genre.name).join(', ') || 'N/A'}`);
+ console.log(` Last.fm Tags: ${((srcTrack as any).lastfmTags || []).join(', ') || 'N/A'}`);
+ console.log(` Mood Tags: ${((srcTrack as any).moodTags || []).join(', ') || 'N/A'}`);
+ }
+
+ console.log("\n" + "-".repeat(100));
+ console.log(`VIBE QUEUE - ${orderedTracks.length} tracks (showing up to 50, SORTED BY MATCH SCORE)`);
+ console.log("-".repeat(100));
+ console.log(`${"#".padEnd(3)} | ${"TRACK".padEnd(35)} | ${"ARTIST".padEnd(20)} | ${"BPM".padEnd(6)} | ${"ENG".padEnd(5)} | ${"VAL".padEnd(5)} | ${"H".padEnd(4)} | ${"S".padEnd(4)} | ${"R".padEnd(4)} | ${"A".padEnd(4)} | MODE | GENRES`);
+ console.log("-".repeat(100));
+
+ orderedTracks.slice(0, 50).forEach((track, i) => {
+ const t = track as any;
+ const title = track.title.substring(0, 33).padEnd(35);
+ const artist = track.album.artist.name.substring(0, 18).padEnd(20);
+ const bpm = track.bpm ? track.bpm.toFixed(0).padEnd(6) : "N/A".padEnd(6);
+ const energy = track.energy !== null ? track.energy.toFixed(2).padEnd(5) : "N/A".padEnd(5);
+ const valence = track.valence !== null ? track.valence.toFixed(2).padEnd(5) : "N/A".padEnd(5);
+ const happy = t.moodHappy !== null ? t.moodHappy.toFixed(2).padEnd(4) : "N/A".padEnd(4);
+ const sad = t.moodSad !== null ? t.moodSad.toFixed(2).padEnd(4) : "N/A".padEnd(4);
+ const relaxed = t.moodRelaxed !== null ? t.moodRelaxed.toFixed(2).padEnd(4) : "N/A".padEnd(4);
+ const aggressive = t.moodAggressive !== null ? t.moodAggressive.toFixed(2).padEnd(4) : "N/A".padEnd(4);
+ const mode = (t.analysisMode || "std").substring(0, 7).padEnd(8);
+ const genres = track.trackGenres.slice(0, 3).map(tg => tg.genre.name).join(", ");
+
+ console.log(`${String(i + 1).padEnd(3)} | ${title} | ${artist} | ${bpm} | ${energy} | ${valence} | ${happy} | ${sad} | ${relaxed} | ${aggressive} | ${mode} | ${genres}`);
+ });
+
+ if (orderedTracks.length > 50) {
+ console.log(`... and ${orderedTracks.length - 50} more tracks`);
+ }
+
+ console.log("=".repeat(100) + "\n");
+ }
+
+ // Transform to match frontend Track interface
+ const transformedTracks = orderedTracks.map((track) => ({
+ id: track.id,
+ title: track.title,
+ duration: track.duration,
+ trackNo: track.trackNo,
+ filePath: track.filePath,
+ artist: {
+ id: track.album.artist.id,
+ name: track.album.artist.name,
+ },
+ album: {
+ id: track.album.id,
+ title: track.album.title,
+ coverArt: track.album.coverUrl,
+ },
+ // Include audio features for vibe mode visualization (if available)
+ ...(vibeSourceFeatures && {
+ audioFeatures: {
+ bpm: track.bpm,
+ energy: track.energy,
+ valence: track.valence,
+ arousal: track.arousal,
+ danceability: track.danceability,
+ keyScale: track.keyScale,
+ instrumentalness: track.instrumentalness,
+ analysisMode: track.analysisMode,
+ // ML Mood predictions for enhanced visualization
+ moodHappy: track.moodHappy,
+ moodSad: track.moodSad,
+ moodRelaxed: track.moodRelaxed,
+ moodAggressive: track.moodAggressive,
+ moodParty: track.moodParty,
+ moodAcoustic: track.moodAcoustic,
+ moodElectronic: track.moodElectronic,
+ },
+ }),
+ }));
+
+ // For vibe mode, keep sorted order. For other modes, shuffle.
+ const finalTracks = type === "vibe"
+ ? transformedTracks
+ : transformedTracks.sort(() => Math.random() - 0.5);
+
+ // Include source features if this was a vibe request
+ const response: any = { tracks: finalTracks };
+ if (vibeSourceFeatures) {
+ response.sourceFeatures = vibeSourceFeatures;
+ }
+
+ res.json(response);
+ } catch (error) {
+ console.error("Radio endpoint error:", error);
+ res.status(500).json({ error: "Failed to get radio tracks" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/listeningState.ts b/backend/src/routes/listeningState.ts
new file mode 100644
index 0000000..df977c6
--- /dev/null
+++ b/backend/src/routes/listeningState.ts
@@ -0,0 +1,108 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+
+const router = Router();
+
+router.use(requireAuth);
+
+const listeningStateSchema = z.object({
+ kind: z.enum(["music", "book"]),
+ entityId: z.string(),
+ trackId: z.string().optional(),
+ positionMs: z.number().int().min(0),
+});
+
+// POST /listening-state
+router.post("/", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const data = listeningStateSchema.parse(req.body);
+
+ const state = await prisma.listeningState.upsert({
+ where: {
+ userId_kind_entityId: {
+ userId,
+ kind: data.kind,
+ entityId: data.entityId,
+ },
+ },
+ create: {
+ userId,
+ ...data,
+ },
+ update: {
+ trackId: data.trackId,
+ positionMs: data.positionMs,
+ updatedAt: new Date(),
+ },
+ });
+
+ res.json(state);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: error.errors });
+ }
+ console.error("Update listening state error:", error);
+ res.status(500).json({ error: "Failed to update listening state" });
+ }
+});
+
+// GET /listening-state
+router.get("/", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const { kind, entityId } = req.query;
+
+ if (!kind || !entityId) {
+ return res
+ .status(400)
+ .json({ error: "kind and entityId required" });
+ }
+
+ const state = await prisma.listeningState.findUnique({
+ where: {
+ userId_kind_entityId: {
+ userId,
+ kind: kind as string,
+ entityId: entityId as string,
+ },
+ },
+ });
+
+ if (!state) {
+ return res.status(404).json({ error: "No listening state found" });
+ }
+
+ res.json(state);
+ } catch (error) {
+ console.error("Get listening state error:", error);
+ res.status(500).json({ error: "Failed to get listening state" });
+ }
+});
+
+// GET /listening-state/recent (for "Continue Listening")
+router.get("/recent", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const { limit = "10" } = req.query;
+
+ const states = await prisma.listeningState.findMany({
+ where: { userId },
+ orderBy: { updatedAt: "desc" },
+ take: parseInt(limit as string, 10),
+ });
+
+ res.json(states);
+ } catch (error) {
+ console.error("Get recent listening states error:", error);
+ res.status(500).json({
+ error: "Failed to get recent listening states",
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/mixes.ts b/backend/src/routes/mixes.ts
new file mode 100644
index 0000000..f6919e5
--- /dev/null
+++ b/backend/src/routes/mixes.ts
@@ -0,0 +1,684 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { programmaticPlaylistService } from "../services/programmaticPlaylists";
+import { prisma } from "../utils/db";
+import { redisClient } from "../utils/redis";
+
+const router = Router();
+
+router.use(requireAuthOrToken);
+
+const getRequestUserId = (req: any): string | null => {
+ return req.user?.id || req.session?.userId || null;
+};
+
+/**
+ * @openapi
+ * /mixes:
+ * get:
+ * summary: Get all programmatic mixes
+ * description: Returns all auto-generated mixes (era-based, genre-based, top tracks, rediscover, artist similar, random discovery)
+ * tags: [Mixes]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * responses:
+ * 200:
+ * description: List of programmatic mixes
+ * content:
+ * application/json:
+ * schema:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * example: "era-2000"
+ * type:
+ * type: string
+ * enum: [era, genre, top-tracks, rediscover, artist-similar, random-discovery]
+ * name:
+ * type: string
+ * example: "Your 2000s Mix"
+ * description:
+ * type: string
+ * example: "Music from the 2000s in your library"
+ * trackIds:
+ * type: array
+ * items:
+ * type: string
+ * coverUrls:
+ * type: array
+ * items:
+ * type: string
+ * description: Album covers for mosaic display (up to 4)
+ * trackCount:
+ * type: integer
+ * example: 42
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get("/", async (req, res) => {
+ try {
+ const userId = getRequestUserId(req);
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ // Check cache first (mixes are expensive to compute)
+ const cacheKey = `mixes:${userId}`;
+ const cached = await redisClient.get(cacheKey);
+
+ if (cached) {
+ return res.json(JSON.parse(cached));
+ }
+
+ // Generate all mixes
+ const mixes = await programmaticPlaylistService.generateAllMixes(
+ userId
+ );
+
+ // Cache for 1 hour
+ await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
+
+ res.json(mixes);
+ } catch (error) {
+ console.error("Get mixes error:", error);
+ res.status(500).json({ error: "Failed to get mixes" });
+ }
+});
+
+/**
+ * @openapi
+ * /mixes/mood:
+ * post:
+ * summary: Generate a custom mood-based mix on demand
+ * description: Creates a personalized mix based on audio features like valence, energy, tempo, etc.
+ * tags: [Mixes]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * valence:
+ * type: object
+ * properties:
+ * min:
+ * type: number
+ * minimum: 0
+ * maximum: 1
+ * max:
+ * type: number
+ * minimum: 0
+ * maximum: 1
+ * energy:
+ * type: object
+ * properties:
+ * min:
+ * type: number
+ * max:
+ * type: number
+ * danceability:
+ * type: object
+ * properties:
+ * min:
+ * type: number
+ * max:
+ * type: number
+ * acousticness:
+ * type: object
+ * properties:
+ * min:
+ * type: number
+ * max:
+ * type: number
+ * instrumentalness:
+ * type: object
+ * properties:
+ * min:
+ * type: number
+ * max:
+ * type: number
+ * bpm:
+ * type: object
+ * properties:
+ * min:
+ * type: number
+ * max:
+ * type: number
+ * keyScale:
+ * type: string
+ * enum: [major, minor]
+ * limit:
+ * type: integer
+ * default: 15
+ * responses:
+ * 200:
+ * description: Generated mood mix with full track details
+ * 400:
+ * description: Not enough tracks matching criteria
+ * 401:
+ * description: Not authenticated
+ */
+router.post("/mood", async (req, res) => {
+ try {
+ const userId = getRequestUserId(req);
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ const params = req.body;
+
+ // Validate parameters
+ const validKeys = [
+ // Basic audio features
+ 'valence', 'energy', 'danceability', 'acousticness', 'instrumentalness', 'arousal', 'bpm', 'keyScale',
+ // ML mood predictions
+ 'moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic',
+ // Other
+ 'limit'
+ ];
+ for (const key of Object.keys(params)) {
+ if (!validKeys.includes(key)) {
+ return res.status(400).json({ error: `Invalid parameter: ${key}` });
+ }
+ }
+
+ const mix = await programmaticPlaylistService.generateMoodOnDemand(userId, params);
+
+ if (!mix) {
+ return res.status(400).json({
+ error: "Not enough tracks matching your criteria",
+ suggestion: "Try widening your parameters or wait for more tracks to be analyzed"
+ });
+ }
+
+ // Load full track details
+ const tracks = await prisma.track.findMany({
+ where: {
+ id: { in: mix.trackIds },
+ },
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // Preserve mix order
+ const orderedTracks = mix.trackIds
+ .map((id: string) => tracks.find((t) => t.id === id))
+ .filter((t: any) => t !== undefined);
+
+ console.log(`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`);
+
+ res.json({
+ ...mix,
+ tracks: orderedTracks,
+ });
+ } catch (error) {
+ console.error("Generate mood mix error:", error);
+ res.status(500).json({ error: "Failed to generate mood mix" });
+ }
+});
+
+/**
+ * Available mood presets for the UI
+ */
+router.get("/mood/presets", async (req, res) => {
+ // Presets use ML mood predictions for more accurate matching
+ // These mirror the logic used in programmatic mixes (Chill Mix, Party Mix, etc.)
+ const presets = [
+ {
+ id: "happy",
+ name: "Happy & Upbeat",
+ color: "from-yellow-400 to-orange-500",
+ params: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 }, energy: { min: 0.4 } },
+ },
+ {
+ id: "sad",
+ name: "Melancholic",
+ color: "from-blue-600 to-indigo-700",
+ params: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
+ },
+ {
+ id: "chill",
+ name: "Chill & Relaxed",
+ color: "from-teal-400 to-cyan-500",
+ params: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 }, energy: { max: 0.55 } },
+ },
+ {
+ id: "energetic",
+ name: "High Energy",
+ color: "from-red-500 to-orange-600",
+ params: { arousal: { min: 0.6 }, energy: { min: 0.65 }, moodRelaxed: { max: 0.4 } },
+ },
+ {
+ id: "focus",
+ name: "Focus Mode",
+ color: "from-purple-600 to-violet-700",
+ params: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 }, energy: { min: 0.2, max: 0.6 } },
+ },
+ {
+ id: "dance",
+ name: "Dance Party",
+ color: "from-pink-500 to-rose-600",
+ params: { moodParty: { min: 0.5 }, danceability: { min: 0.6 }, energy: { min: 0.5 } },
+ },
+ {
+ id: "acoustic",
+ name: "Acoustic Vibes",
+ color: "from-amber-500 to-yellow-600",
+ params: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
+ },
+ {
+ id: "dark",
+ name: "Dark & Moody",
+ color: "from-gray-700 to-slate-800",
+ params: { moodAggressive: { min: 0.4 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
+ },
+ {
+ id: "romantic",
+ name: "Romantic",
+ color: "from-rose-500 to-pink-600",
+ params: { moodRelaxed: { min: 0.3 }, moodAggressive: { max: 0.3 }, acousticness: { min: 0.3 }, energy: { max: 0.6 } },
+ },
+ {
+ id: "workout",
+ name: "Workout Beast",
+ color: "from-green-500 to-emerald-600",
+ params: { arousal: { min: 0.6 }, energy: { min: 0.7 }, moodRelaxed: { max: 0.4 }, bpm: { min: 110 } },
+ },
+ {
+ id: "sleepy",
+ name: "Sleep & Unwind",
+ color: "from-indigo-400 to-purple-500",
+ params: { moodRelaxed: { min: 0.5 }, energy: { max: 0.35 }, moodAggressive: { max: 0.2 } },
+ },
+ {
+ id: "confident",
+ name: "Confidence Boost",
+ color: "from-amber-400 to-orange-500",
+ params: { moodHappy: { min: 0.4 }, moodParty: { min: 0.3 }, energy: { min: 0.5 }, danceability: { min: 0.5 } },
+ },
+ ];
+
+ res.json(presets);
+});
+
+/**
+ * Save user's mood mix preferences
+ * These preferences are used to generate "Your Mood Mix" in the mix rotation
+ */
+router.post("/mood/save-preferences", async (req, res) => {
+ try {
+ const userId = getRequestUserId(req);
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ const params = req.body;
+
+ // Validate that at least some params are provided
+ if (!params || Object.keys(params).length === 0) {
+ return res.status(400).json({ error: "No mood parameters provided" });
+ }
+
+ // Save to user record
+ await prisma.user.update({
+ where: { id: userId },
+ data: { moodMixParams: params }
+ });
+
+ // Invalidate mix cache so the new mood mix appears
+ const cacheKey = `mixes:${userId}`;
+ await redisClient.del(cacheKey);
+
+ console.log(`[MIXES] Saved mood mix preferences for user ${userId}`);
+
+ res.json({ success: true, message: "Mood preferences saved" });
+ } catch (error) {
+ console.error("Save mood preferences error:", error);
+ res.status(500).json({ error: "Failed to save mood preferences" });
+ }
+});
+
+/**
+ * @openapi
+ * /mixes/refresh:
+ * post:
+ * summary: Force refresh all mixes
+ * description: Clears cache and regenerates all programmatic mixes
+ * tags: [Mixes]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * responses:
+ * 200:
+ * description: Mixes refreshed successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * message:
+ * type: string
+ * example: "Mixes refreshed"
+ * mixes:
+ * type: array
+ * items:
+ * type: object
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.post("/refresh", async (req, res) => {
+ try {
+ const userId = getRequestUserId(req);
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+
+ // Clear cache
+ const cacheKey = `mixes:${userId}`;
+ await redisClient.del(cacheKey);
+
+ // Regenerate mixes with random selection (not date-based)
+ const mixes = await programmaticPlaylistService.generateAllMixes(
+ userId,
+ true
+ );
+
+ // Cache for 1 hour
+ await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
+
+ res.json({ message: "Mixes refreshed", mixes });
+ } catch (error) {
+ console.error("Refresh mixes error:", error);
+ res.status(500).json({ error: "Failed to refresh mixes" });
+ }
+});
+
+/**
+ * @openapi
+ * /mixes/{id}/save:
+ * post:
+ * summary: Save a mix as a playlist
+ * description: Creates a new playlist with all tracks from the specified mix
+ * tags: [Mixes]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Mix ID to save as playlist
+ * requestBody:
+ * required: false
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * name:
+ * type: string
+ * description: Optional custom name for the playlist (defaults to mix name)
+ * responses:
+ * 200:
+ * description: Playlist created successfully
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * name:
+ * type: string
+ * trackCount:
+ * type: integer
+ * 404:
+ * description: Mix not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.post("/:id/save", async (req, res) => {
+ try {
+ const userId = getRequestUserId(req);
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+ const mixId = req.params.id;
+ const customName = req.body.name;
+
+ // Get the mix with track details
+ const cacheKey = `mixes:${userId}`;
+ let mixes;
+
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ mixes = JSON.parse(cached);
+ } else {
+ mixes = await programmaticPlaylistService.generateAllMixes(userId);
+ await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
+ }
+
+ const mix = mixes.find((m: any) => m.id === mixId);
+
+ if (!mix) {
+ return res.status(404).json({ error: "Mix not found" });
+ }
+
+ const existingPlaylist = await prisma.playlist.findFirst({
+ where: {
+ userId,
+ mixId: mix.id,
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ if (existingPlaylist) {
+ return res.status(409).json({
+ error: "Mix already saved as playlist",
+ playlistId: existingPlaylist.id,
+ name: existingPlaylist.name,
+ });
+ }
+
+ // Create playlist
+ const playlist = await prisma.playlist.create({
+ data: {
+ userId,
+ mixId: mix.id,
+ name: customName || mix.name,
+ isPublic: false,
+ },
+ });
+
+ // Add all tracks to the playlist
+ const playlistItems = mix.trackIds.map(
+ (trackId: string, index: number) => ({
+ playlistId: playlist.id,
+ trackId,
+ sort: index,
+ })
+ );
+
+ await prisma.playlistItem.createMany({
+ data: playlistItems,
+ });
+
+ console.log(
+ `[MIXES] Saved mix ${mixId} as playlist ${playlist.id} (${mix.trackIds.length} tracks)`
+ );
+
+ res.json({
+ id: playlist.id,
+ name: playlist.name,
+ trackCount: mix.trackIds.length,
+ });
+ } catch (error) {
+ console.error("Save mix as playlist error:", error);
+ res.status(500).json({ error: "Failed to save mix as playlist" });
+ }
+});
+
+/**
+ * @openapi
+ * /mixes/{id}:
+ * get:
+ * summary: Get a specific mix with full track details
+ * tags: [Mixes]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema:
+ * type: string
+ * description: Mix ID (e.g., "era-2000", "genre-rock", "top-tracks")
+ * responses:
+ * 200:
+ * description: Mix with full track details
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * id:
+ * type: string
+ * type:
+ * type: string
+ * name:
+ * type: string
+ * description:
+ * type: string
+ * trackIds:
+ * type: array
+ * items:
+ * type: string
+ * coverUrls:
+ * type: array
+ * items:
+ * type: string
+ * trackCount:
+ * type: integer
+ * tracks:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Track'
+ * 404:
+ * description: Mix not found
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get("/:id", async (req, res) => {
+ try {
+ const userId = getRequestUserId(req);
+ if (!userId) {
+ return res.status(401).json({ error: "Not authenticated" });
+ }
+ const mixId = req.params.id;
+
+ // Get all mixes (from cache if available)
+ const cacheKey = `mixes:${userId}`;
+ let mixes;
+
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ mixes = JSON.parse(cached);
+ } else {
+ mixes = await programmaticPlaylistService.generateAllMixes(userId);
+ await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
+ }
+
+ // Find the specific mix
+ const mix = mixes.find((m: any) => m.id === mixId);
+
+ if (!mix) {
+ return res.status(404).json({ error: "Mix not found" });
+ }
+
+ // Load full track details
+ const tracks = await prisma.track.findMany({
+ where: {
+ id: {
+ in: mix.trackIds,
+ },
+ },
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // Preserve mix order
+ const orderedTracks = mix.trackIds
+ .map((id: string) => tracks.find((t) => t.id === id))
+ .filter((t: any) => t !== undefined);
+
+ res.json({
+ ...mix,
+ tracks: orderedTracks,
+ });
+ } catch (error) {
+ console.error("Get mix error:", error);
+ res.status(500).json({ error: "Failed to get mix" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts
new file mode 100644
index 0000000..99b5ec8
--- /dev/null
+++ b/backend/src/routes/notifications.ts
@@ -0,0 +1,711 @@
+import { Router, Response } from "express";
+import { notificationService } from "../services/notificationService";
+import { AuthenticatedRequest, requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+
+const router = Router();
+
+/**
+ * GET /notifications
+ * Get all uncleared notifications for the current user
+ */
+router.get(
+ "/",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ console.log(
+ `[Notifications] Fetching notifications for user ${
+ req.user!.id
+ }`
+ );
+ const notifications = await notificationService.getForUser(
+ req.user!.id
+ );
+ console.log(
+ `[Notifications] Found ${notifications.length} notifications`
+ );
+ res.json(notifications);
+ } catch (error: any) {
+ console.error("Error fetching notifications:", error);
+ res.status(500).json({ error: "Failed to fetch notifications" });
+ }
+ }
+);
+
+/**
+ * GET /notifications/unread-count
+ * Get count of unread notifications
+ */
+router.get(
+ "/unread-count",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const count = await notificationService.getUnreadCount(
+ req.user!.id
+ );
+ res.json({ count });
+ } catch (error: any) {
+ console.error("Error fetching unread count:", error);
+ res.status(500).json({ error: "Failed to fetch unread count" });
+ }
+ }
+);
+
+/**
+ * POST /notifications/:id/read
+ * Mark a notification as read
+ */
+router.post(
+ "/:id/read",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ await notificationService.markAsRead(req.params.id, req.user!.id);
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Error marking notification as read:", error);
+ res.status(500).json({
+ error: "Failed to mark notification as read",
+ });
+ }
+ }
+);
+
+/**
+ * POST /notifications/read-all
+ * Mark all notifications as read
+ */
+router.post(
+ "/read-all",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ await notificationService.markAllAsRead(req.user!.id);
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Error marking all notifications as read:", error);
+ res.status(500).json({
+ error: "Failed to mark all notifications as read",
+ });
+ }
+ }
+);
+
+/**
+ * POST /notifications/:id/clear
+ * Clear (dismiss) a notification
+ */
+router.post(
+ "/:id/clear",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ await notificationService.clear(req.params.id, req.user!.id);
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Error clearing notification:", error);
+ res.status(500).json({ error: "Failed to clear notification" });
+ }
+ }
+);
+
+/**
+ * POST /notifications/clear-all
+ * Clear all notifications
+ */
+router.post(
+ "/clear-all",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ await notificationService.clearAll(req.user!.id);
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Error clearing all notifications:", error);
+ res.status(500).json({
+ error: "Failed to clear all notifications",
+ });
+ }
+ }
+);
+
+// ============================================
+// Download History Endpoints
+// ============================================
+
+/**
+ * GET /notifications/downloads/history
+ * Get completed/failed downloads that haven't been cleared
+ */
+router.get(
+ "/downloads/history",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const downloads = await prisma.downloadJob.findMany({
+ where: {
+ userId: req.user!.id,
+ status: { in: ["completed", "failed", "exhausted"] },
+ cleared: false,
+ },
+ orderBy: { updatedAt: "desc" },
+ take: 50,
+ });
+ res.json(downloads);
+ } catch (error: any) {
+ console.error("Error fetching download history:", error);
+ res.status(500).json({ error: "Failed to fetch download history" });
+ }
+ }
+);
+
+/**
+ * GET /notifications/downloads/active
+ * Get active downloads (pending/processing)
+ */
+router.get(
+ "/downloads/active",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ const downloads = await prisma.downloadJob.findMany({
+ where: {
+ userId: req.user!.id,
+ status: { in: ["pending", "processing"] },
+ },
+ orderBy: { createdAt: "desc" },
+ });
+ res.json(downloads);
+ } catch (error: any) {
+ console.error("Error fetching active downloads:", error);
+ res.status(500).json({ error: "Failed to fetch active downloads" });
+ }
+ }
+);
+
+/**
+ * POST /notifications/downloads/:id/clear
+ * Clear a download from history
+ */
+router.post(
+ "/downloads/:id/clear",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ await prisma.downloadJob.updateMany({
+ where: {
+ id: req.params.id,
+ userId: req.user!.id,
+ },
+ data: { cleared: true },
+ });
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Error clearing download:", error);
+ res.status(500).json({ error: "Failed to clear download" });
+ }
+ }
+);
+
+/**
+ * POST /notifications/downloads/clear-all
+ * Clear all completed/failed downloads from history
+ */
+router.post(
+ "/downloads/clear-all",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ await prisma.downloadJob.updateMany({
+ where: {
+ userId: req.user!.id,
+ status: { in: ["completed", "failed", "exhausted"] },
+ cleared: false,
+ },
+ data: { cleared: true },
+ });
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Error clearing all downloads:", error);
+ res.status(500).json({ error: "Failed to clear all downloads" });
+ }
+ }
+);
+
+/**
+ * POST /notifications/downloads/:id/retry
+ * Retry a failed download
+ */
+router.post(
+ "/downloads/:id/retry",
+ requireAuth,
+ async (req: AuthenticatedRequest, res: Response) => {
+ try {
+ // Get the failed download
+ const failedJob = await prisma.downloadJob.findFirst({
+ where: {
+ id: req.params.id,
+ userId: req.user!.id,
+ status: { in: ["failed", "exhausted"] },
+ },
+ });
+
+ if (!failedJob) {
+ return res
+ .status(404)
+ .json({ error: "Download not found or not failed" });
+ }
+
+ // If this was a pending-track retry job, re-run the pending-track retry flow
+ const metadata = failedJob.metadata as Record<
+ string,
+ unknown
+ > | null;
+ if (metadata?.downloadType === "pending-track-retry") {
+ const playlistId = metadata.playlistId as string | undefined;
+ const pendingTrackId = metadata.pendingTrackId as
+ | string
+ | undefined;
+
+ if (!playlistId || !pendingTrackId) {
+ return res.status(400).json({
+ error: "Cannot retry: missing playlistId or pendingTrackId",
+ });
+ }
+
+ // Mark old job as cleared
+ await prisma.downloadJob.update({
+ where: { id: failedJob.id },
+ data: { cleared: true },
+ });
+
+ // Validate playlist ownership and pending track exists
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+ if (!playlist || playlist.userId !== req.user!.id) {
+ return res
+ .status(404)
+ .json({ error: "Playlist not found" });
+ }
+
+ const pendingTrack =
+ await prisma.playlistPendingTrack.findUnique({
+ where: { id: pendingTrackId },
+ });
+ if (!pendingTrack) {
+ return res
+ .status(404)
+ .json({ error: "Pending track not found" });
+ }
+
+ const retryTargetId =
+ pendingTrack.albumMbid ||
+ pendingTrack.artistMbid ||
+ `pendingTrack:${pendingTrack.id}`;
+
+ const newJobRecord = await prisma.downloadJob.create({
+ data: {
+ userId: req.user!.id,
+ subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
+ type: "track",
+ targetMbid: retryTargetId,
+ artistMbid: pendingTrack.artistMbid,
+ status: "processing",
+ attempts: 1,
+ startedAt: new Date(),
+ metadata: {
+ downloadType: "pending-track-retry",
+ source: "soulseek",
+ playlistId,
+ pendingTrackId,
+ spotifyArtist: pendingTrack.spotifyArtist,
+ spotifyTitle: pendingTrack.spotifyTitle,
+ spotifyAlbum: pendingTrack.spotifyAlbum,
+ albumMbid: pendingTrack.albumMbid,
+ },
+ },
+ });
+
+ const { soulseekService } = await import(
+ "../services/soulseek"
+ );
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+
+ const settings = await getSystemSettings();
+ if (!settings?.musicPath) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: "Music path not configured",
+ completedAt: new Date(),
+ },
+ });
+ return res.json({
+ success: false,
+ newJobId: newJobRecord.id,
+ error: "Music path not configured",
+ });
+ }
+
+ if (
+ !settings?.soulseekUsername ||
+ !settings?.soulseekPassword
+ ) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: "Soulseek credentials not configured",
+ completedAt: new Date(),
+ },
+ });
+ return res.json({
+ success: false,
+ newJobId: newJobRecord.id,
+ error: "Soulseek credentials not configured",
+ });
+ }
+
+ const albumName =
+ pendingTrack.spotifyAlbum !== "Unknown Album"
+ ? pendingTrack.spotifyAlbum
+ : pendingTrack.spotifyArtist;
+
+ const searchResult = await soulseekService.searchTrack(
+ pendingTrack.spotifyArtist,
+ pendingTrack.spotifyTitle
+ );
+
+ if (
+ !searchResult.found ||
+ searchResult.allMatches.length === 0
+ ) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: "No matching files found",
+ completedAt: new Date(),
+ },
+ });
+ return res.json({
+ success: false,
+ newJobId: newJobRecord.id,
+ error: "No matching files found",
+ });
+ }
+
+ // Start download in background (don't await)
+ soulseekService
+ .downloadBestMatch(
+ pendingTrack.spotifyArtist,
+ pendingTrack.spotifyTitle,
+ albumName,
+ searchResult.allMatches,
+ settings.musicPath
+ )
+ .then(async (result) => {
+ if (result.success) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ metadata: {
+ ...(newJobRecord.metadata as any),
+ filePath: result.filePath,
+ },
+ },
+ });
+
+ try {
+ const { scanQueue } = await import(
+ "../workers/queues"
+ );
+ await scanQueue.add(
+ "scan",
+ {
+ userId: req.user!.id,
+ source: "retry-pending-track",
+ albumMbid:
+ pendingTrack.albumMbid || undefined,
+ artistMbid:
+ pendingTrack.artistMbid ||
+ undefined,
+ },
+ {
+ priority: 1,
+ removeOnComplete: true,
+ }
+ );
+ } catch {
+ // Best-effort; job status already reflects download
+ }
+ } else {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: result.error || "Download failed",
+ completedAt: new Date(),
+ },
+ });
+ }
+ })
+ .catch(async (error) => {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: error?.message || "Download exception",
+ completedAt: new Date(),
+ },
+ });
+ });
+
+ return res.json({ success: true, newJobId: newJobRecord.id });
+ }
+
+ // If this was a spotify_import job, retry with Soulseek first
+ if (metadata?.downloadType === "spotify_import") {
+ const artistName = metadata.artistName as string;
+ const albumTitle = metadata.albumTitle as string;
+
+ if (!artistName || !albumTitle) {
+ return res
+ .status(400)
+ .json({
+ error: "Cannot retry: missing artist/album info",
+ });
+ }
+
+ // Mark old job as cleared
+ await prisma.downloadJob.update({
+ where: { id: failedJob.id },
+ data: { cleared: true },
+ });
+
+ // Create a NEW download job record for the retry
+ const newJobRecord = await prisma.downloadJob.create({
+ data: {
+ userId: req.user!.id,
+ type: "album",
+ targetMbid:
+ failedJob.targetMbid || `retry_${Date.now()}`,
+ artistMbid: failedJob.artistMbid,
+ subject: `${artistName} - ${albumTitle}`,
+ status: "processing",
+ attempts: 1,
+ startedAt: new Date(),
+ metadata: {
+ ...metadata,
+ retryAttempt: true,
+ },
+ },
+ });
+
+ // Try Soulseek first (async)
+ const { soulseekService } = await import(
+ "../services/soulseek"
+ );
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+
+ const settings = await getSystemSettings();
+ const musicPath = settings?.musicPath;
+
+ if (!musicPath) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: "Music path not configured",
+ completedAt: new Date(),
+ },
+ });
+ return res.json({
+ success: false,
+ newJobId: newJobRecord.id,
+ error: "Music path not configured",
+ });
+ }
+
+ // Build track from album info (single track search using album as title)
+ const tracks = [
+ {
+ artist: artistName,
+ title: albumTitle,
+ album: albumTitle,
+ },
+ ];
+
+ console.log(
+ `[Retry] Trying Soulseek for ${artistName} - ${albumTitle}`
+ );
+
+ // Run Soulseek search async
+ soulseekService
+ .searchAndDownloadBatch(tracks, musicPath, 4)
+ .then(async (result) => {
+ if (result.successful > 0) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ error: null,
+ metadata: {
+ ...metadata,
+ source: "soulseek",
+ tracksDownloaded: result.successful,
+ files: result.files,
+ },
+ },
+ });
+ console.log(
+ `[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}`
+ );
+
+ // Trigger library scan
+ const { scanQueue } = await import(
+ "../workers/queues"
+ );
+ await scanQueue.add("scan", {
+ paths: [],
+ fullScan: false,
+ userId: req.user!.id,
+ source: "retry-spotify-import",
+ });
+ } else {
+ // Soulseek failed, try Lidarr if we have an MBID
+ console.log(
+ `[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}`
+ );
+
+ if (
+ failedJob.targetMbid &&
+ !failedJob.targetMbid.startsWith("retry_")
+ ) {
+ const { simpleDownloadManager } = await import(
+ "../services/simpleDownloadManager"
+ );
+ const lidarrResult =
+ await simpleDownloadManager.startDownload(
+ newJobRecord.id,
+ artistName,
+ albumTitle,
+ failedJob.targetMbid,
+ req.user!.id,
+ false
+ );
+
+ if (!lidarrResult.success) {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error:
+ lidarrResult.error ||
+ "Both Soulseek and Lidarr failed",
+ completedAt: new Date(),
+ },
+ });
+ }
+ } else {
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: "No tracks found on Soulseek, no MBID for Lidarr fallback",
+ completedAt: new Date(),
+ },
+ });
+ }
+ }
+ })
+ .catch(async (error) => {
+ console.error(`[Retry] Soulseek error:`, error);
+ await prisma.downloadJob.update({
+ where: { id: newJobRecord.id },
+ data: {
+ status: "failed",
+ error: error?.message || "Soulseek error",
+ completedAt: new Date(),
+ },
+ });
+ });
+
+ return res.json({ success: true, newJobId: newJobRecord.id });
+ }
+
+ // Validate that we have the required MBIDs
+ if (!failedJob.targetMbid) {
+ return res
+ .status(400)
+ .json({ error: "Cannot retry: missing album MBID" });
+ }
+
+ // Mark old job as cleared
+ await prisma.downloadJob.update({
+ where: { id: failedJob.id },
+ data: { cleared: true },
+ });
+
+ // Extract parameters from the failed job
+ // Subject is typically "Artist - Album" format
+ const subjectParts = failedJob.subject.split(" - ");
+ const artistName = subjectParts[0] || failedJob.subject;
+ const albumTitle =
+ (metadata?.albumTitle as string) ||
+ subjectParts[1] ||
+ failedJob.subject;
+
+ // Create a NEW download job record for the retry
+ const newJobRecord = await prisma.downloadJob.create({
+ data: {
+ userId: req.user!.id,
+ type: failedJob.type as "artist" | "album",
+ targetMbid: failedJob.targetMbid,
+ artistMbid: failedJob.artistMbid,
+ subject: failedJob.subject,
+ status: "pending",
+ metadata: metadata || {},
+ },
+ });
+
+ // Import the download manager dynamically to avoid circular deps
+ const { simpleDownloadManager } = await import(
+ "../services/simpleDownloadManager"
+ );
+
+ // Start download with the correct positional arguments
+ // startDownload(jobId, artistName, albumTitle, albumMbid, userId, isDiscovery)
+ const result = await simpleDownloadManager.startDownload(
+ newJobRecord.id,
+ artistName,
+ albumTitle,
+ failedJob.targetMbid,
+ req.user!.id,
+ false // isDiscovery
+ );
+
+ res.json({
+ success: result.success,
+ newJobId: newJobRecord.id,
+ error: result.error,
+ });
+ } catch (error: any) {
+ console.error("Error retrying download:", error);
+ res.status(500).json({ error: "Failed to retry download" });
+ }
+ }
+);
+
+export default router;
diff --git a/backend/src/routes/offline.ts b/backend/src/routes/offline.ts
new file mode 100644
index 0000000..04e139c
--- /dev/null
+++ b/backend/src/routes/offline.ts
@@ -0,0 +1,286 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+
+const router = Router();
+
+router.use(requireAuth);
+
+const downloadAlbumSchema = z.object({
+ quality: z.enum(["original", "high", "medium", "low"]).optional(),
+});
+
+// POST /offline/albums/:id/download
+router.post("/albums/:id/download", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const albumId = req.params.id;
+ const { quality } = downloadAlbumSchema.parse(req.body);
+
+ // Get user's default quality if not specified
+ let selectedQuality = quality;
+ if (!selectedQuality) {
+ const settings = await prisma.userSettings.findUnique({
+ where: { userId },
+ });
+ selectedQuality = (settings?.playbackQuality as any) || "medium";
+ }
+
+ // Get album with tracks
+ const album = await prisma.album.findUnique({
+ where: { id: albumId },
+ include: {
+ tracks: {
+ orderBy: { trackNo: "asc" },
+ },
+ artist: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ });
+
+ if (!album) {
+ return res.status(404).json({ error: "Album not found" });
+ }
+
+ // Calculate total size estimate
+ const avgSizeMb: Record = {
+ original: 30, // FLAC
+ high: 10, // MP3 320
+ medium: 6, // MP3 192
+ low: 4, // MP3 128
+ };
+
+ const estimatedSizeMb =
+ album.tracks.length * avgSizeMb[selectedQuality];
+
+ // Check user's cache limit
+ const settings = await prisma.userSettings.findUnique({
+ where: { userId },
+ });
+
+ if (settings) {
+ const currentCacheSize = await prisma.cachedTrack.aggregate({
+ where: { userId },
+ _sum: { fileSizeMb: true },
+ });
+
+ const currentSize = currentCacheSize._sum.fileSizeMb || 0;
+
+ if (currentSize + estimatedSizeMb > settings.maxCacheSizeMb) {
+ return res.status(400).json({
+ error: "Cache size limit exceeded",
+ currentSize,
+ maxSize: settings.maxCacheSizeMb,
+ needed: estimatedSizeMb,
+ });
+ }
+ }
+
+ // Create download job (tracks to be downloaded by mobile client)
+ const downloadJob = {
+ albumId: album.id,
+ albumTitle: album.title,
+ artistName: album.artist.name,
+ quality: selectedQuality,
+ tracks: album.tracks.map((track) => ({
+ trackId: track.id,
+ title: track.title,
+ trackNo: track.trackNo,
+ duration: track.duration,
+ streamUrl: `/library/tracks/${track.id}/stream?quality=${selectedQuality}`,
+ })),
+ estimatedSizeMb,
+ };
+
+ res.json(downloadJob);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: error.errors });
+ }
+ console.error("Create download job error:", error);
+ res.status(500).json({ error: "Failed to create download job" });
+ }
+});
+
+// POST /offline/tracks/:id/complete (called by mobile after download)
+router.post("/tracks/:id/complete", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const trackId = req.params.id;
+ const { localPath, quality, fileSizeMb } = req.body;
+
+ if (!localPath || !quality || !fileSizeMb) {
+ return res
+ .status(400)
+ .json({ error: "localPath, quality, and fileSizeMb required" });
+ }
+
+ const cachedTrack = await prisma.cachedTrack.upsert({
+ where: {
+ userId_trackId_quality: {
+ userId,
+ trackId,
+ quality,
+ },
+ },
+ create: {
+ userId,
+ trackId,
+ localPath,
+ quality,
+ fileSizeMb: parseFloat(fileSizeMb),
+ },
+ update: {
+ localPath,
+ fileSizeMb: parseFloat(fileSizeMb),
+ lastAccessedAt: new Date(),
+ },
+ });
+
+ res.json(cachedTrack);
+ } catch (error) {
+ console.error("Complete track download error:", error);
+ res.status(500).json({ error: "Failed to complete download" });
+ }
+});
+
+// GET /offline/albums
+router.get("/albums", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+
+ // Get all cached tracks grouped by album
+ const cachedTracks = await prisma.cachedTrack.findMany({
+ where: { userId },
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // Group by album
+ const albumsMap = new Map();
+
+ for (const cached of cachedTracks) {
+ const albumId = cached.track.album.id;
+
+ if (!albumsMap.has(albumId)) {
+ albumsMap.set(albumId, {
+ album: cached.track.album,
+ tracks: [],
+ totalSizeMb: 0,
+ });
+ }
+
+ const albumData = albumsMap.get(albumId);
+ albumData.tracks.push({
+ ...cached.track,
+ cachedPath: cached.localPath,
+ cachedQuality: cached.quality,
+ cachedSizeMb: cached.fileSizeMb,
+ });
+ albumData.totalSizeMb += cached.fileSizeMb;
+ }
+
+ const albums = Array.from(albumsMap.values()).map((data) => ({
+ ...data.album,
+ cachedTracks: data.tracks,
+ totalSizeMb: data.totalSizeMb,
+ }));
+
+ res.json(albums);
+ } catch (error) {
+ console.error("Get cached albums error:", error);
+ res.status(500).json({ error: "Failed to get cached albums" });
+ }
+});
+
+// DELETE /offline/albums/:id
+router.delete("/albums/:id", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const albumId = req.params.id;
+
+ // Get all cached tracks for this album
+ const cachedTracks = await prisma.cachedTrack.findMany({
+ where: {
+ userId,
+ track: {
+ albumId,
+ },
+ },
+ });
+
+ // Delete all cached tracks for this album
+ await prisma.cachedTrack.deleteMany({
+ where: {
+ userId,
+ track: {
+ albumId,
+ },
+ },
+ });
+
+ res.json({
+ message: "Album removed from cache",
+ deletedCount: cachedTracks.length,
+ });
+ } catch (error) {
+ console.error("Delete cached album error:", error);
+ res.status(500).json({ error: "Failed to delete cached album" });
+ }
+});
+
+// GET /offline/stats
+router.get("/stats", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+
+ const [settings, cacheStats] = await Promise.all([
+ prisma.userSettings.findUnique({
+ where: { userId },
+ }),
+ prisma.cachedTrack.aggregate({
+ where: { userId },
+ _sum: { fileSizeMb: true },
+ _count: true,
+ }),
+ ]);
+
+ const usedMb = cacheStats._sum.fileSizeMb || 0;
+ const maxMb = settings?.maxCacheSizeMb || 5120;
+ const trackCount = cacheStats._count || 0;
+
+ res.json({
+ usedMb,
+ maxMb,
+ availableMb: maxMb - usedMb,
+ percentUsed: (usedMb / maxMb) * 100,
+ trackCount,
+ });
+ } catch (error) {
+ console.error("Get cache stats error:", error);
+ res.status(500).json({ error: "Failed to get cache stats" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/onboarding.ts b/backend/src/routes/onboarding.ts
new file mode 100644
index 0000000..edca0cd
--- /dev/null
+++ b/backend/src/routes/onboarding.ts
@@ -0,0 +1,475 @@
+import { Router } from "express";
+import { prisma } from "../utils/db";
+import bcrypt from "bcrypt";
+import { z } from "zod";
+import axios from "axios";
+import crypto from "crypto";
+import { encryptField } from "../utils/systemSettings";
+import { writeEnvFile } from "../utils/envWriter";
+import { generateToken, requireAuth } from "../middleware/auth";
+
+const router = Router();
+
+// Validation schemas
+const registerSchema = z.object({
+ username: z.string().min(3).max(50),
+ password: z.string().min(6),
+});
+
+const lidarrConfigSchema = z.object({
+ url: z.string().url().optional().or(z.literal("")),
+ apiKey: z.string().optional().or(z.literal("")),
+ enabled: z.boolean(),
+});
+
+const audiobookshelfConfigSchema = z.object({
+ url: z.string().url().optional().or(z.literal("")),
+ apiKey: z.string().optional().or(z.literal("")),
+ enabled: z.boolean(),
+});
+
+const soulseekConfigSchema = z.object({
+ username: z.string().optional().or(z.literal("")),
+ password: z.string().optional().or(z.literal("")),
+ enabled: z.boolean(),
+});
+
+const enrichmentConfigSchema = z.object({
+ enabled: z.boolean(),
+});
+
+/**
+ * Generate a secure encryption key for settings encryption
+ * This is called automatically during first user registration
+ */
+async function ensureEncryptionKey(): Promise {
+ // Check if encryption key already exists
+ if (
+ process.env.SETTINGS_ENCRYPTION_KEY &&
+ process.env.SETTINGS_ENCRYPTION_KEY !==
+ "default-encryption-key-change-me"
+ ) {
+ console.log("[ONBOARDING] Encryption key already exists");
+ return;
+ }
+
+ // Generate a secure 32-byte encryption key
+ const encryptionKey = crypto.randomBytes(32).toString("base64");
+
+ console.log(
+ "[ONBOARDING] Generating encryption key for settings security..."
+ );
+
+ try {
+ // Write to .env file
+ await writeEnvFile({
+ SETTINGS_ENCRYPTION_KEY: encryptionKey,
+ });
+
+ // Update the process environment so it's available immediately
+ process.env.SETTINGS_ENCRYPTION_KEY = encryptionKey;
+
+ console.log("[ONBOARDING] Encryption key generated and saved to .env");
+ } catch (error) {
+ console.error("[ONBOARDING] ✗ Failed to save encryption key:", error);
+ throw new Error("Failed to generate encryption key");
+ }
+}
+
+/**
+ * POST /onboarding/register
+ * Step 1: Create user account - returns JWT token like regular login
+ */
+router.post("/register", async (req, res) => {
+ try {
+ console.log("[ONBOARDING] Register attempt for user:", req.body?.username);
+ const { username, password } = registerSchema.parse(req.body);
+
+ // Check if any user exists (first user becomes admin)
+ const userCount = await prisma.user.count();
+ const isFirstUser = userCount === 0;
+
+ // If this is the first user, ensure encryption key is generated
+ if (isFirstUser) {
+ await ensureEncryptionKey();
+ }
+
+ // Check if username is taken
+ const existing = await prisma.user.findUnique({
+ where: { username },
+ });
+
+ if (existing) {
+ console.log("[ONBOARDING] Username already taken:", username);
+ return res.status(400).json({ error: "Username already taken" });
+ }
+
+ // Create user
+ const passwordHash = await bcrypt.hash(password, 10);
+ const user = await prisma.user.create({
+ data: {
+ username,
+ passwordHash,
+ role: isFirstUser ? "admin" : "user",
+ onboardingComplete: false,
+ },
+ });
+
+ // Create default user settings with optimal defaults
+ await prisma.userSettings.create({
+ data: {
+ userId: user.id,
+ playbackQuality: "original",
+ wifiOnly: false,
+ offlineEnabled: false,
+ maxCacheSizeMb: 10240, // 10GB
+ },
+ });
+
+ // Generate JWT token (same as login)
+ const token = generateToken({
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ });
+
+ console.log("[ONBOARDING] User created successfully:", user.username);
+ res.json({
+ token,
+ user: {
+ id: user.id,
+ username: user.username,
+ role: user.role,
+ onboardingComplete: false,
+ },
+ });
+ } catch (err: any) {
+ if (err instanceof z.ZodError) {
+ console.error("[ONBOARDING] Validation error:", err.errors);
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: err.errors });
+ }
+ console.error("Registration error:", err);
+ res.status(500).json({ error: "Failed to create account" });
+ }
+});
+
+/**
+ * POST /onboarding/lidarr
+ * Step 2a: Configure Lidarr integration
+ */
+router.post("/lidarr", requireAuth, async (req, res) => {
+ try {
+ const config = lidarrConfigSchema.parse(req.body);
+
+ // If not enabled, just save as disabled
+ if (!config.enabled) {
+ const settings = await prisma.systemSettings.findFirst();
+ if (settings) {
+ await prisma.systemSettings.update({
+ where: { id: settings.id },
+ data: { lidarrEnabled: false },
+ });
+ }
+ return res.json({ success: true, tested: false });
+ }
+
+ // Test connection if enabled (non-blocking - save anyway)
+ let connectionTested = false;
+ if (config.url && config.apiKey) {
+ try {
+ const response = await axios.get(
+ `${config.url}/api/v1/system/status`,
+ {
+ headers: { "X-Api-Key": config.apiKey },
+ timeout: 5000,
+ }
+ );
+
+ if (response.status === 200) {
+ connectionTested = true;
+ console.log("Lidarr connection test successful");
+ }
+ } catch (error: any) {
+ console.warn(
+ " Lidarr connection test failed (saved anyway):",
+ error.message
+ );
+ // Don't block - just log the warning
+ }
+ }
+
+ // Save to system settings (even if connection test failed)
+ await prisma.systemSettings.upsert({
+ where: { id: "default" },
+ create: {
+ id: "default",
+ lidarrEnabled: config.enabled,
+ lidarrUrl: config.url || null,
+ lidarrApiKey: encryptField(config.apiKey),
+ },
+ update: {
+ lidarrEnabled: config.enabled,
+ lidarrUrl: config.url || null,
+ lidarrApiKey: encryptField(config.apiKey),
+ },
+ });
+
+ res.json({
+ success: true,
+ tested: connectionTested,
+ warning: connectionTested
+ ? null
+ : "Connection test failed but settings saved. You can test again in Settings.",
+ });
+ } catch (err: any) {
+ if (err instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: err.errors });
+ }
+ console.error("Lidarr config error:", err);
+ res.status(500).json({ error: "Failed to save configuration" });
+ }
+});
+
+/**
+ * POST /onboarding/audiobookshelf
+ * Step 2b: Configure Audiobookshelf integration
+ */
+router.post("/audiobookshelf", requireAuth, async (req, res) => {
+ try {
+ const config = audiobookshelfConfigSchema.parse(req.body);
+
+ // If not enabled, just save as disabled
+ if (!config.enabled) {
+ const settings = await prisma.systemSettings.findFirst();
+ if (settings) {
+ await prisma.systemSettings.update({
+ where: { id: settings.id },
+ data: { audiobookshelfEnabled: false },
+ });
+ }
+ return res.json({ success: true, tested: false });
+ }
+
+ // Test connection if enabled (non-blocking - save anyway)
+ let connectionTested = false;
+ if (config.url && config.apiKey) {
+ try {
+ const response = await axios.get(`${config.url}/api/me`, {
+ headers: { Authorization: `Bearer ${config.apiKey}` },
+ timeout: 5000,
+ });
+
+ if (response.status === 200) {
+ connectionTested = true;
+ console.log("Audiobookshelf connection test successful");
+ }
+ } catch (error: any) {
+ console.warn(
+ " Audiobookshelf connection test failed (saved anyway):",
+ error.message
+ );
+ // Don't block - just log the warning
+ }
+ }
+
+ // Save to system settings (even if connection test failed)
+ await prisma.systemSettings.upsert({
+ where: { id: "default" },
+ create: {
+ id: "default",
+ audiobookshelfEnabled: config.enabled,
+ audiobookshelfUrl: config.url || null,
+ audiobookshelfApiKey: encryptField(config.apiKey),
+ },
+ update: {
+ audiobookshelfEnabled: config.enabled,
+ audiobookshelfUrl: config.url || null,
+ audiobookshelfApiKey: encryptField(config.apiKey),
+ },
+ });
+
+ res.json({
+ success: true,
+ tested: connectionTested,
+ warning: connectionTested
+ ? null
+ : "Connection test failed but settings saved. You can test again in Settings.",
+ });
+ } catch (err: any) {
+ if (err instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: err.errors });
+ }
+ console.error("Audiobookshelf config error:", err);
+ res.status(500).json({ error: "Failed to save configuration" });
+ }
+});
+
+/**
+ * POST /onboarding/soulseek
+ * Step 2c: Configure Soulseek integration (direct connection via slsk-client)
+ */
+router.post("/soulseek", requireAuth, async (req, res) => {
+ try {
+ const config = soulseekConfigSchema.parse(req.body);
+
+ // If not enabled, clear credentials
+ if (!config.enabled) {
+ await prisma.systemSettings.upsert({
+ where: { id: "default" },
+ create: {
+ id: "default",
+ soulseekUsername: null,
+ soulseekPassword: null,
+ },
+ update: {
+ soulseekUsername: null,
+ soulseekPassword: null,
+ },
+ });
+ return res.json({ success: true, tested: false });
+ }
+
+ // If enabled, require credentials
+ if (!config.username || !config.password) {
+ return res.status(400).json({
+ error: "Soulseek username and password are required",
+ });
+ }
+
+ // Save to system settings
+ await prisma.systemSettings.upsert({
+ where: { id: "default" },
+ create: {
+ id: "default",
+ soulseekUsername: config.username,
+ soulseekPassword: encryptField(config.password),
+ },
+ update: {
+ soulseekUsername: config.username,
+ soulseekPassword: encryptField(config.password),
+ },
+ });
+
+ res.json({ success: true, tested: true });
+ } catch (err: any) {
+ if (err instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: err.errors });
+ }
+ console.error("Soulseek config error:", err);
+ res.status(500).json({ error: "Failed to save configuration" });
+ }
+});
+
+/**
+ * POST /onboarding/enrichment
+ * Step 3: Configure metadata enrichment
+ */
+router.post("/enrichment", requireAuth, async (req, res) => {
+ try {
+ const config = enrichmentConfigSchema.parse(req.body);
+
+ // Update user settings
+ await prisma.user.update({
+ where: { id: req.user!.id },
+ data: {
+ enrichmentSettings: {
+ enabled: config.enabled,
+ lastRun: null,
+ },
+ },
+ });
+
+ res.json({ success: true });
+ } catch (err: any) {
+ if (err instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: err.errors });
+ }
+ console.error("Enrichment config error:", err);
+ res.status(500).json({ error: "Failed to save configuration" });
+ }
+});
+
+/**
+ * POST /onboarding/complete
+ * Final step: Mark onboarding as complete
+ */
+router.post("/complete", requireAuth, async (req, res) => {
+ try {
+ await prisma.user.update({
+ where: { id: req.user!.id },
+ data: { onboardingComplete: true },
+ });
+
+ console.log("[ONBOARDING] User completed onboarding:", req.user!.id);
+ res.json({ success: true });
+ } catch (err: any) {
+ console.error("Onboarding complete error:", err);
+ res.status(500).json({ error: "Failed to complete onboarding" });
+ }
+});
+
+/**
+ * GET /onboarding/status
+ * Check if user needs onboarding
+ */
+router.get("/status", async (req, res) => {
+ try {
+ // Check if any users exist in the system
+ const userCount = await prisma.user.count();
+ const hasAccount = userCount > 0;
+
+ // Check for JWT token in Authorization header
+ const authHeader = req.headers.authorization;
+ const token = authHeader?.startsWith("Bearer ")
+ ? authHeader.substring(7)
+ : null;
+
+ // If no token, return whether any users exist
+ if (!token) {
+ return res.json({
+ needsOnboarding: !hasAccount,
+ hasAccount,
+ });
+ }
+
+ // Try to verify token and check onboarding status
+ try {
+ const jwt = require("jsonwebtoken");
+ const JWT_SECRET =
+ process.env.JWT_SECRET ||
+ "your-secret-key-change-in-production";
+ const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
+
+ const user = await prisma.user.findUnique({
+ where: { id: decoded.userId },
+ select: { onboardingComplete: true },
+ });
+
+ res.json({
+ needsOnboarding: !user?.onboardingComplete,
+ hasAccount: true,
+ });
+ } catch {
+ // Invalid token - return basic status
+ res.json({
+ needsOnboarding: !hasAccount,
+ hasAccount,
+ });
+ }
+ } catch (err: any) {
+ console.error("Onboarding status error:", err);
+ res.status(500).json({ error: "Failed to check status" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/playbackState.ts b/backend/src/routes/playbackState.ts
new file mode 100644
index 0000000..b4245e2
--- /dev/null
+++ b/backend/src/routes/playbackState.ts
@@ -0,0 +1,149 @@
+import express from "express";
+import { prisma } from "../utils/db";
+import { requireAuth } from "../middleware/auth";
+
+const router = express.Router();
+
+// Get current playback state for the authenticated user
+router.get("/", requireAuth, async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ const playbackState = await prisma.playbackState.findUnique({
+ where: { userId },
+ });
+
+ if (!playbackState) {
+ return res.json(null);
+ }
+
+ res.json(playbackState);
+ } catch (error) {
+ console.error("Get playback state error:", error);
+ res.status(500).json({ error: "Failed to get playback state" });
+ }
+});
+
+// Update current playback state for the authenticated user
+router.post("/", requireAuth, async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const {
+ playbackType,
+ trackId,
+ audiobookId,
+ podcastId,
+ queue,
+ currentIndex,
+ isShuffle,
+ } = req.body;
+
+ // Validate required field
+ if (!playbackType) {
+ return res.status(400).json({ error: "playbackType is required" });
+ }
+
+ // Validate playback type
+ const validPlaybackTypes = ["track", "audiobook", "podcast"];
+ if (!validPlaybackTypes.includes(playbackType)) {
+ console.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`);
+ return res.status(400).json({ error: "Invalid playbackType" });
+ }
+
+ // Limit queue size and sanitize queue items to prevent database issues
+ let safeQueue: any[] | null = null;
+ if (Array.isArray(queue) && queue.length > 0) {
+ // Only keep essential fields from each queue item to reduce JSON size
+ // Filter out any invalid items first
+ try {
+ safeQueue = queue
+ .slice(0, 100)
+ .filter((item: any) => item && item.id) // Must have at least an ID
+ .map((item: any) => ({
+ id: String(item.id || ""),
+ title: String(item.title || "Unknown").substring(0, 500), // Limit title length
+ duration: Number(item.duration) || 0,
+ artist: item.artist ? {
+ id: String(item.artist.id || ""),
+ name: String(item.artist.name || "Unknown").substring(0, 200),
+ } : null,
+ album: item.album ? {
+ id: String(item.album.id || ""),
+ title: String(item.album.title || "Unknown").substring(0, 500),
+ coverArt: item.album.coverArt ? String(item.album.coverArt).substring(0, 1000) : null,
+ } : null,
+ }));
+
+ // If sanitization removed all items, set to null
+ if (safeQueue.length === 0) {
+ safeQueue = null;
+ }
+ } catch (sanitizeError: any) {
+ console.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message);
+ safeQueue = null; // Fall back to null queue
+ }
+ }
+
+ const safeCurrentIndex = Math.min(
+ Math.max(0, currentIndex || 0),
+ safeQueue?.length ? safeQueue.length - 1 : 0
+ );
+
+ const playbackState = await prisma.playbackState.upsert({
+ where: { userId },
+ update: {
+ playbackType,
+ trackId: trackId || null,
+ audiobookId: audiobookId || null,
+ podcastId: podcastId || null,
+ queue: safeQueue,
+ currentIndex: safeCurrentIndex,
+ isShuffle: isShuffle || false,
+ },
+ create: {
+ userId,
+ playbackType,
+ trackId: trackId || null,
+ audiobookId: audiobookId || null,
+ podcastId: podcastId || null,
+ queue: safeQueue,
+ currentIndex: safeCurrentIndex,
+ isShuffle: isShuffle || false,
+ },
+ });
+
+ res.json(playbackState);
+ } catch (error: any) {
+ console.error("[PlaybackState] Error saving state:", error?.message || error);
+ console.error("[PlaybackState] Full error:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
+ if (error?.code) {
+ console.error("[PlaybackState] Error code:", error.code);
+ }
+ if (error?.meta) {
+ console.error("[PlaybackState] Prisma meta:", error.meta);
+ }
+ // Return more specific error for debugging
+ res.status(500).json({
+ error: "Internal server error",
+ details: error?.message || "Unknown error"
+ });
+ }
+});
+
+// Clear playback state (when user stops playback completely)
+router.delete("/", requireAuth, async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ await prisma.playbackState.delete({
+ where: { userId },
+ });
+
+ res.json({ success: true });
+ } catch (error) {
+ console.error("Delete playback state error:", error);
+ res.status(500).json({ error: "Failed to delete playback state" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/playlists.ts b/backend/src/routes/playlists.ts
new file mode 100644
index 0000000..791e683
--- /dev/null
+++ b/backend/src/routes/playlists.ts
@@ -0,0 +1,985 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+import { sessionLog } from "../utils/playlistLogger";
+
+const router = Router();
+
+router.use(requireAuthOrToken);
+
+const createPlaylistSchema = z.object({
+ name: z.string().min(1).max(200),
+ isPublic: z.boolean().optional().default(false),
+});
+
+const addTrackSchema = z.object({
+ trackId: z.string(),
+});
+
+// GET /playlists
+router.get("/", async (req, res) => {
+ try {
+ const userId = req.user.id;
+
+ // Get user's hidden playlists
+ const hiddenPlaylists = await prisma.hiddenPlaylist.findMany({
+ where: { userId },
+ select: { playlistId: true },
+ });
+ const hiddenPlaylistIds = new Set(
+ hiddenPlaylists.map((h) => h.playlistId)
+ );
+
+ const playlists = await prisma.playlist.findMany({
+ where: {
+ OR: [{ userId }, { isPublic: true }],
+ },
+ orderBy: { createdAt: "desc" },
+ include: {
+ user: {
+ select: {
+ username: true,
+ },
+ },
+ items: {
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { sort: "asc" },
+ },
+ },
+ });
+
+ const playlistsWithCounts = playlists.map((playlist) => ({
+ ...playlist,
+ trackCount: playlist.items.length,
+ isOwner: playlist.userId === userId,
+ isHidden: hiddenPlaylistIds.has(playlist.id),
+ }));
+
+ // Debug: log shared playlists with user info
+ const sharedPlaylists = playlistsWithCounts.filter((p) => !p.isOwner);
+ if (sharedPlaylists.length > 0) {
+ console.log(
+ `[Playlists] Found ${sharedPlaylists.length} shared playlists for user ${userId}:`
+ );
+ sharedPlaylists.forEach((p) => {
+ console.log(
+ ` - "${p.name}" by ${
+ p.user?.username || "UNKNOWN"
+ } (owner: ${p.userId})`
+ );
+ });
+ }
+
+ res.json(playlistsWithCounts);
+ } catch (error) {
+ console.error("Get playlists error:", error);
+ res.status(500).json({ error: "Failed to get playlists" });
+ }
+});
+
+// POST /playlists
+router.post("/", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const data = createPlaylistSchema.parse(req.body);
+
+ const playlist = await prisma.playlist.create({
+ data: {
+ userId,
+ name: data.name,
+ isPublic: data.isPublic,
+ },
+ });
+
+ res.json(playlist);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: error.errors });
+ }
+ console.error("Create playlist error:", error);
+ res.status(500).json({ error: "Failed to create playlist" });
+ }
+});
+
+// GET /playlists/:id
+router.get("/:id", async (req, res) => {
+ try {
+ const userId = req.user.id;
+
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: req.params.id },
+ include: {
+ user: {
+ select: {
+ username: true,
+ },
+ },
+ items: {
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { sort: "asc" },
+ },
+ pendingTracks: {
+ orderBy: { sort: "asc" },
+ },
+ },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ // Check access permissions
+ if (!playlist.isPublic && playlist.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ // Format playlist items
+ const formattedItems = playlist.items.map((item) => ({
+ ...item,
+ type: "track" as const,
+ track: {
+ ...item.track,
+ album: {
+ ...item.track.album,
+ coverArt: item.track.album.coverUrl,
+ },
+ },
+ }));
+
+ // Format pending tracks
+ const formattedPending = playlist.pendingTracks.map((pending) => ({
+ id: pending.id,
+ type: "pending" as const,
+ sort: pending.sort,
+ pending: {
+ id: pending.id,
+ artist: pending.spotifyArtist,
+ title: pending.spotifyTitle,
+ album: pending.spotifyAlbum,
+ previewUrl: pending.deezerPreviewUrl,
+ },
+ }));
+
+ // Merge and sort by position
+ const mergedItems = [
+ ...formattedItems.map((item) => ({ ...item, sort: item.sort })),
+ ...formattedPending,
+ ].sort((a, b) => a.sort - b.sort);
+
+ res.json({
+ ...playlist,
+ isOwner: playlist.userId === userId,
+ trackCount: playlist.items.length,
+ pendingCount: playlist.pendingTracks.length,
+ items: formattedItems,
+ pendingTracks: formattedPending,
+ mergedItems,
+ });
+ } catch (error) {
+ console.error("Get playlist error:", error);
+ res.status(500).json({ error: "Failed to get playlist" });
+ }
+});
+
+// PUT /playlists/:id
+router.put("/:id", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const data = createPlaylistSchema.parse(req.body);
+
+ // Check ownership
+ const existing = await prisma.playlist.findUnique({
+ where: { id: req.params.id },
+ });
+
+ if (!existing) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (existing.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ const playlist = await prisma.playlist.update({
+ where: { id: req.params.id },
+ data: {
+ name: data.name,
+ isPublic: data.isPublic,
+ },
+ });
+
+ res.json(playlist);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: error.errors });
+ }
+ console.error("Update playlist error:", error);
+ res.status(500).json({ error: "Failed to update playlist" });
+ }
+});
+
+// POST /playlists/:id/hide - Hide any playlist from your view
+router.post("/:id/hide", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const playlistId = req.params.id;
+
+ // Check playlist exists
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ // User must own the playlist OR it must be public (shared)
+ if (playlist.userId !== userId && !playlist.isPublic) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ // Create hidden record (upsert to handle re-hiding)
+ await prisma.hiddenPlaylist.upsert({
+ where: {
+ userId_playlistId: { userId, playlistId },
+ },
+ create: { userId, playlistId },
+ update: {},
+ });
+
+ res.json({ message: "Playlist hidden", isHidden: true });
+ } catch (error) {
+ console.error("Hide playlist error:", error);
+ res.status(500).json({ error: "Failed to hide playlist" });
+ }
+});
+
+// DELETE /playlists/:id/hide - Unhide a shared playlist
+router.delete("/:id/hide", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const playlistId = req.params.id;
+
+ // Delete hidden record if exists
+ await prisma.hiddenPlaylist.deleteMany({
+ where: { userId, playlistId },
+ });
+
+ res.json({ message: "Playlist unhidden", isHidden: false });
+ } catch (error) {
+ console.error("Unhide playlist error:", error);
+ res.status(500).json({ error: "Failed to unhide playlist" });
+ }
+});
+
+// DELETE /playlists/:id
+router.delete("/:id", async (req, res) => {
+ try {
+ const userId = req.user.id;
+
+ // Check ownership
+ const existing = await prisma.playlist.findUnique({
+ where: { id: req.params.id },
+ });
+
+ if (!existing) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (existing.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ await prisma.playlist.delete({
+ where: { id: req.params.id },
+ });
+
+ res.json({ message: "Playlist deleted" });
+ } catch (error) {
+ console.error("Delete playlist error:", error);
+ res.status(500).json({ error: "Failed to delete playlist" });
+ }
+});
+
+// POST /playlists/:id/items
+router.post("/:id/items", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const parsedBody = addTrackSchema.safeParse(req.body);
+ if (!parsedBody.success) {
+ return res.status(400).json({
+ error: "Invalid request",
+ details: parsedBody.error.errors,
+ });
+ }
+ const { trackId } = parsedBody.data;
+
+ // Check ownership
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: req.params.id },
+ include: {
+ items: {
+ orderBy: { sort: "desc" },
+ take: 1,
+ },
+ },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ // Check if track exists
+ const track = await prisma.track.findUnique({
+ where: { id: trackId },
+ });
+
+ if (!track) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Check if track already in playlist
+ const existing = await prisma.playlistItem.findUnique({
+ where: {
+ playlistId_trackId: {
+ playlistId: req.params.id,
+ trackId,
+ },
+ },
+ });
+
+ if (existing) {
+ return res.status(200).json({
+ message: "Track already in playlist",
+ duplicated: true,
+ item: existing,
+ });
+ }
+
+ // Get next sort position
+ const maxSort = playlist.items[0]?.sort || 0;
+
+ const item = await prisma.playlistItem.create({
+ data: {
+ playlistId: req.params.id,
+ trackId,
+ sort: maxSort + 1,
+ },
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ res.json(item);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: error.errors });
+ }
+ console.error("Add track to playlist error:", error);
+ res.status(500).json({ error: "Failed to add track to playlist" });
+ }
+});
+
+// DELETE /playlists/:id/items/:trackId
+router.delete("/:id/items/:trackId", async (req, res) => {
+ try {
+ const userId = req.user.id;
+
+ // Check ownership
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: req.params.id },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ await prisma.playlistItem.delete({
+ where: {
+ playlistId_trackId: {
+ playlistId: req.params.id,
+ trackId: req.params.trackId,
+ },
+ },
+ });
+
+ res.json({ message: "Track removed from playlist" });
+ } catch (error) {
+ console.error("Remove track from playlist error:", error);
+ res.status(500).json({ error: "Failed to remove track from playlist" });
+ }
+});
+
+// PUT /playlists/:id/items/reorder
+router.put("/:id/items/reorder", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { trackIds } = req.body; // Array of track IDs in new order
+
+ if (!Array.isArray(trackIds)) {
+ return res.status(400).json({ error: "trackIds must be an array" });
+ }
+
+ // Check ownership
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: req.params.id },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ // Update sort order for each track
+ const updates = trackIds.map((trackId, index) =>
+ prisma.playlistItem.update({
+ where: {
+ playlistId_trackId: {
+ playlistId: req.params.id,
+ trackId,
+ },
+ },
+ data: { sort: index },
+ })
+ );
+
+ await prisma.$transaction(updates);
+
+ res.json({ message: "Playlist reordered" });
+ } catch (error) {
+ console.error("Reorder playlist error:", error);
+ res.status(500).json({ error: "Failed to reorder playlist" });
+ }
+});
+
+// ============================================
+// Pending Tracks (from Spotify imports)
+// ============================================
+
+/**
+ * GET /playlists/:id/pending
+ * Get pending tracks for a playlist (tracks from Spotify that haven't been matched yet)
+ */
+router.get("/:id/pending", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const playlistId = req.params.id;
+
+ // Check ownership or public access
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId && !playlist.isPublic) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ const pendingTracks = await prisma.playlistPendingTrack.findMany({
+ where: { playlistId },
+ orderBy: { sort: "asc" },
+ });
+
+ res.json({
+ count: pendingTracks.length,
+ tracks: pendingTracks.map((t) => ({
+ id: t.id,
+ artist: t.spotifyArtist,
+ title: t.spotifyTitle,
+ album: t.spotifyAlbum,
+ position: t.sort,
+ previewUrl: t.deezerPreviewUrl,
+ })),
+ spotifyPlaylistId: playlist.spotifyPlaylistId,
+ });
+ } catch (error) {
+ console.error("Get pending tracks error:", error);
+ res.status(500).json({ error: "Failed to get pending tracks" });
+ }
+});
+
+/**
+ * DELETE /playlists/:id/pending/:trackId
+ * Remove a pending track (user decides they don't want to wait for it)
+ */
+router.delete("/:id/pending/:trackId", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { id: playlistId, trackId: pendingTrackId } = req.params;
+
+ // Check ownership
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ await prisma.playlistPendingTrack.delete({
+ where: { id: pendingTrackId },
+ });
+
+ res.json({ message: "Pending track removed" });
+ } catch (error: any) {
+ if (error.code === "P2025") {
+ return res.status(404).json({ error: "Pending track not found" });
+ }
+ console.error("Delete pending track error:", error);
+ res.status(500).json({ error: "Failed to delete pending track" });
+ }
+});
+
+/**
+ * GET /playlists/:id/pending/:trackId/preview
+ * Get a fresh Deezer preview URL for a pending track (since they expire)
+ */
+router.get("/:id/pending/:trackId/preview", async (req, res) => {
+ try {
+ const { trackId: pendingTrackId } = req.params;
+
+ // Get the pending track
+ const pendingTrack = await prisma.playlistPendingTrack.findUnique({
+ where: { id: pendingTrackId },
+ });
+
+ if (!pendingTrack) {
+ return res.status(404).json({ error: "Pending track not found" });
+ }
+
+ // Fetch fresh Deezer preview URL
+ const { deezerService } = await import("../services/deezer");
+ const previewUrl = await deezerService.getTrackPreview(
+ pendingTrack.spotifyArtist,
+ pendingTrack.spotifyTitle
+ );
+
+ if (!previewUrl) {
+ return res
+ .status(404)
+ .json({ error: "No preview available on Deezer" });
+ }
+
+ // Update the stored preview URL for future use
+ await prisma.playlistPendingTrack.update({
+ where: { id: pendingTrackId },
+ data: { deezerPreviewUrl: previewUrl },
+ });
+
+ res.json({ previewUrl });
+ } catch (error: any) {
+ console.error("Get preview URL error:", error);
+ res.status(500).json({ error: "Failed to get preview URL" });
+ }
+});
+
+/**
+ * POST /playlists/:id/pending/:trackId/retry
+ * Retry downloading a failed/pending track from Soulseek
+ * Returns immediately and downloads in background
+ */
+router.post("/:id/pending/:trackId/retry", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const { id: playlistId, trackId: pendingTrackId } = req.params;
+
+ sessionLog(
+ "PENDING-RETRY",
+ `Request: userId=${userId} playlistId=${playlistId} pendingTrackId=${pendingTrackId}`
+ );
+
+ // Check ownership
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) {
+ sessionLog(
+ "PENDING-RETRY",
+ `Playlist not found: ${playlistId}`,
+ "WARN"
+ );
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId) {
+ sessionLog(
+ "PENDING-RETRY",
+ `Access denied: playlistId=${playlistId} userId=${userId}`,
+ "WARN"
+ );
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ // Get the pending track
+ const pendingTrack = await prisma.playlistPendingTrack.findUnique({
+ where: { id: pendingTrackId },
+ });
+
+ if (!pendingTrack) {
+ sessionLog(
+ "PENDING-RETRY",
+ `Pending track not found: ${pendingTrackId}`,
+ "WARN"
+ );
+ return res.status(404).json({ error: "Pending track not found" });
+ }
+
+ sessionLog(
+ "PENDING-RETRY",
+ `Pending track: artist="${pendingTrack.spotifyArtist}" title="${pendingTrack.spotifyTitle}" album="${pendingTrack.spotifyAlbum}"`
+ );
+
+ // Create a DownloadJob so this retry appears in Activity (active/history)
+ const retryTargetId =
+ pendingTrack.albumMbid ||
+ pendingTrack.artistMbid ||
+ `pendingTrack:${pendingTrack.id}`;
+
+ const downloadJob = await prisma.downloadJob.create({
+ data: {
+ userId,
+ subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
+ type: "track",
+ targetMbid: retryTargetId,
+ artistMbid: pendingTrack.artistMbid,
+ status: "processing",
+ attempts: 1,
+ startedAt: new Date(),
+ metadata: {
+ downloadType: "pending-track-retry",
+ source: "soulseek",
+ playlistId,
+ pendingTrackId,
+ spotifyArtist: pendingTrack.spotifyArtist,
+ spotifyTitle: pendingTrack.spotifyTitle,
+ spotifyAlbum: pendingTrack.spotifyAlbum,
+ albumMbid: pendingTrack.albumMbid,
+ },
+ },
+ });
+
+ sessionLog(
+ "PENDING-RETRY",
+ `Created download job: downloadJobId=${downloadJob.id} target=${retryTargetId}`
+ );
+
+ // Import soulseek service and try to download
+ const { soulseekService } = await import("../services/soulseek");
+ const { getSystemSettings } = await import("../utils/systemSettings");
+
+ const settings = await getSystemSettings();
+ if (!settings?.musicPath) {
+ sessionLog("PENDING-RETRY", `Music path not configured`, "WARN");
+ await prisma.downloadJob.update({
+ where: { id: downloadJob.id },
+ data: {
+ status: "failed",
+ error: "Music path not configured",
+ completedAt: new Date(),
+ },
+ });
+ return res.status(400).json({ error: "Music path not configured" });
+ }
+
+ if (!settings?.soulseekUsername || !settings?.soulseekPassword) {
+ sessionLog(
+ "PENDING-RETRY",
+ `Soulseek credentials not configured`,
+ "WARN"
+ );
+ await prisma.downloadJob.update({
+ where: { id: downloadJob.id },
+ data: {
+ status: "failed",
+ error: "Soulseek credentials not configured",
+ completedAt: new Date(),
+ },
+ });
+ return res
+ .status(400)
+ .json({ error: "Soulseek credentials not configured" });
+ }
+
+ // Use a better album name if possible - extract from stored title or use artist name
+ const albumName =
+ pendingTrack.spotifyAlbum !== "Unknown Album"
+ ? pendingTrack.spotifyAlbum
+ : pendingTrack.spotifyArtist; // Use artist as fallback folder name
+
+ console.log(
+ `[Retry] Starting download for: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
+ );
+ sessionLog(
+ "PENDING-RETRY",
+ `Search: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
+ );
+
+ // First do a quick search to see if track is available (15s timeout)
+ // This way we can tell the user immediately if it's not found
+ const searchResult = await soulseekService.searchTrack(
+ pendingTrack.spotifyArtist,
+ pendingTrack.spotifyTitle
+ );
+
+ if (!searchResult.found || searchResult.allMatches.length === 0) {
+ console.log(`[Retry] ✗ No results found on Soulseek`);
+ sessionLog("PENDING-RETRY", `No results found on Soulseek`, "INFO");
+
+ await prisma.downloadJob.update({
+ where: { id: downloadJob.id },
+ data: {
+ status: "failed",
+ error: "No matching files found",
+ completedAt: new Date(),
+ },
+ });
+
+ return res.status(200).json({
+ success: false,
+ message: "Track not found on Soulseek",
+ error: "No matching files found",
+ });
+ }
+
+ console.log(
+ `[Retry] ✓ Found ${searchResult.allMatches.length} results, starting download in background`
+ );
+ sessionLog(
+ "PENDING-RETRY",
+ `Found ${searchResult.allMatches.length} candidate(s); starting background download`
+ );
+
+ // Return immediately - download happens in background
+ res.json({
+ success: true,
+ message: "Download started",
+ note: `Found ${searchResult.allMatches.length} sources. Downloading... Track will appear after scan.`,
+ downloadJobId: downloadJob.id,
+ });
+
+ // Start download in background (don't await)
+ soulseekService
+ .downloadBestMatch(
+ pendingTrack.spotifyArtist,
+ pendingTrack.spotifyTitle,
+ albumName,
+ searchResult.allMatches,
+ settings.musicPath
+ )
+ .then(async (result) => {
+ if (result.success) {
+ console.log(
+ `[Retry] ✓ Download complete: ${result.filePath}`
+ );
+ sessionLog(
+ "PENDING-RETRY",
+ `Download complete: filePath=${result.filePath}`
+ );
+
+ await prisma.downloadJob.update({
+ where: { id: downloadJob.id },
+ data: {
+ status: "completed",
+ completedAt: new Date(),
+ metadata: {
+ ...(downloadJob.metadata as any),
+ filePath: result.filePath,
+ },
+ },
+ });
+
+ // Trigger a library scan to add the track and reconcile pending
+ try {
+ const { scanQueue } = await import("../workers/queues");
+ const scanJob = await scanQueue.add(
+ "scan",
+ {
+ userId,
+ source: "retry-pending-track",
+ albumMbid: pendingTrack.albumMbid || undefined,
+ artistMbid:
+ pendingTrack.artistMbid || undefined,
+ },
+ {
+ priority: 1, // High priority
+ removeOnComplete: true,
+ }
+ );
+ console.log(
+ `[Retry] Queued library scan to reconcile pending tracks`
+ );
+ sessionLog(
+ "PENDING-RETRY",
+ `Queued library scan (bullJobId=${
+ scanJob.id ?? "unknown"
+ })`
+ );
+ } catch (scanError) {
+ console.error(
+ `[Retry] Failed to queue scan:`,
+ scanError
+ );
+ sessionLog(
+ "PENDING-RETRY",
+ `Failed to queue scan: ${
+ (scanError as any)?.message || scanError
+ }`,
+ "ERROR"
+ );
+ }
+ } else {
+ console.log(`[Retry] ✗ Download failed: ${result.error}`);
+ sessionLog(
+ "PENDING-RETRY",
+ `Download failed: ${result.error || "unknown error"}`,
+ "WARN"
+ );
+
+ await prisma.downloadJob.update({
+ where: { id: downloadJob.id },
+ data: {
+ status: "failed",
+ error: result.error || "Download failed",
+ completedAt: new Date(),
+ },
+ });
+ }
+ })
+ .catch((error) => {
+ console.error(`[Retry] Download error:`, error);
+ sessionLog(
+ "PENDING-RETRY",
+ `Download exception: ${error?.message || error}`,
+ "ERROR"
+ );
+
+ prisma.downloadJob
+ .update({
+ where: { id: downloadJob.id },
+ data: {
+ status: "failed",
+ error: error?.message || "Download exception",
+ completedAt: new Date(),
+ },
+ })
+ .catch(() => undefined);
+ });
+ } catch (error: any) {
+ console.error("Retry pending track error:", error);
+ sessionLog(
+ "PENDING-RETRY",
+ `Handler error: ${error?.message || error}`,
+ "ERROR"
+ );
+ res.status(500).json({
+ error: "Failed to retry download",
+ details: error.message,
+ });
+ }
+});
+
+/**
+ * POST /playlists/:id/pending/reconcile
+ * Manually trigger reconciliation for a specific playlist
+ */
+router.post("/:id/pending/reconcile", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const playlistId = req.params.id;
+
+ // Check ownership
+ const playlist = await prisma.playlist.findUnique({
+ where: { id: playlistId },
+ });
+
+ if (!playlist) {
+ return res.status(404).json({ error: "Playlist not found" });
+ }
+
+ if (playlist.userId !== userId) {
+ return res.status(403).json({ error: "Access denied" });
+ }
+
+ // Import and run reconciliation
+ const { spotifyImportService } = await import(
+ "../services/spotifyImport"
+ );
+ const result = await spotifyImportService.reconcilePendingTracks();
+
+ res.json({
+ message: "Reconciliation complete",
+ tracksAdded: result.tracksAdded,
+ playlistsUpdated: result.playlistsUpdated,
+ });
+ } catch (error) {
+ console.error("Reconcile pending tracks error:", error);
+ res.status(500).json({ error: "Failed to reconcile pending tracks" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/plays.ts b/backend/src/routes/plays.ts
new file mode 100644
index 0000000..5e1d3e4
--- /dev/null
+++ b/backend/src/routes/plays.ts
@@ -0,0 +1,84 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+
+const router = Router();
+
+router.use(requireAuth);
+
+const playSchema = z.object({
+ trackId: z.string(),
+});
+
+// POST /plays
+router.post("/", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const { trackId } = playSchema.parse(req.body);
+
+ // Verify track exists
+ const track = await prisma.track.findUnique({
+ where: { id: trackId },
+ });
+
+ if (!track) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ const play = await prisma.play.create({
+ data: {
+ userId,
+ trackId,
+ },
+ });
+
+ res.json(play);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid request", details: error.errors });
+ }
+ console.error("Create play error:", error);
+ res.status(500).json({ error: "Failed to log play" });
+ }
+});
+
+// GET /plays (recent plays for user)
+router.get("/", async (req, res) => {
+ try {
+ const userId = req.session.userId!;
+ const { limit = "50" } = req.query;
+
+ const plays = await prisma.play.findMany({
+ where: { userId },
+ orderBy: { playedAt: "desc" },
+ take: parseInt(limit as string, 10),
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: {
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ res.json(plays);
+ } catch (error) {
+ console.error("Get plays error:", error);
+ res.status(500).json({ error: "Failed to get plays" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/podcasts.ts b/backend/src/routes/podcasts.ts
new file mode 100644
index 0000000..47b2899
--- /dev/null
+++ b/backend/src/routes/podcasts.ts
@@ -0,0 +1,1560 @@
+import { Router } from "express";
+import { requireAuth, requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { rssParserService } from "../services/rss-parser";
+import { podcastCacheService } from "../services/podcastCache";
+import axios from "axios";
+import fs from "fs";
+
+const router = Router();
+
+/**
+ * POST /podcasts/sync-covers
+ * Manually trigger podcast cover caching
+ * Downloads and caches all podcast/episode covers locally
+ */
+router.post("/sync-covers", requireAuth, async (req, res) => {
+ try {
+ const { notificationService } = await import("../services/notificationService");
+ console.log(" Starting podcast cover sync...");
+
+ const podcastResult = await podcastCacheService.syncAllCovers();
+ const episodeResult = await podcastCacheService.syncEpisodeCovers();
+
+ // Send notification to user
+ await notificationService.notifySystem(
+ req.user!.id,
+ "Podcast Covers Synced",
+ `Synced ${podcastResult.cached || 0} podcast covers and ${episodeResult.cached || 0} episode covers`
+ );
+
+ res.json({
+ success: true,
+ podcasts: podcastResult,
+ episodes: episodeResult,
+ });
+ } catch (error: any) {
+ console.error("Podcast cover sync failed:", error);
+ res.status(500).json({
+ error: "Sync failed",
+ message: error.message,
+ });
+ }
+});
+
+router.use(requireAuthOrToken);
+
+/**
+ * GET /podcasts
+ * Get all podcasts the user is subscribed to
+ */
+router.get("/", async (req, res) => {
+ try {
+ const subscriptions = await prisma.podcastSubscription.findMany({
+ where: { userId: req.user!.id },
+ include: {
+ podcast: {
+ include: {
+ episodes: {
+ orderBy: { publishedAt: "desc" },
+ take: 5, // Get latest 5 episodes per podcast
+ include: {
+ progress: {
+ where: { userId: req.user!.id },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: { subscribedAt: "desc" },
+ });
+
+ const podcasts = subscriptions.map((sub) => {
+ const podcast = sub.podcast;
+ return {
+ id: podcast.id,
+ title: podcast.title,
+ author: podcast.author,
+ description: podcast.description,
+ coverUrl: podcast.localCoverPath
+ ? `/podcasts/${podcast.id}/cover`
+ : podcast.imageUrl, // Fallback to original URL if not cached
+ episodeCount: podcast.episodeCount,
+ autoDownloadEpisodes: false, // Per-podcast auto-download not yet implemented
+ episodes: podcast.episodes.map((ep) => ({
+ id: ep.id,
+ title: ep.title,
+ description: ep.description,
+ duration: ep.duration,
+ publishedAt: ep.publishedAt,
+ coverUrl: ep.localCoverPath
+ ? `/podcasts/episodes/${ep.id}/cover`
+ : ep.imageUrl, // Fallback to original URL
+ progress: ep.progress[0]
+ ? {
+ currentTime: ep.progress[0].currentTime,
+ progress:
+ ep.progress[0].duration > 0
+ ? (ep.progress[0].currentTime /
+ ep.progress[0].duration) *
+ 100
+ : 0,
+ isFinished: ep.progress[0].isFinished,
+ lastPlayedAt: ep.progress[0].lastPlayedAt,
+ }
+ : null,
+ })),
+ };
+ });
+
+ res.json(podcasts);
+ } catch (error: any) {
+ console.error("Error fetching podcasts:", error);
+ res.status(500).json({
+ error: "Failed to fetch podcasts",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/discover/top
+ * Get top podcasts - just search iTunes like the search bar does
+ */
+router.get("/discover/top", requireAuthOrToken, async (req, res) => {
+ try {
+ const { limit = "20" } = req.query;
+ const podcastLimit = Math.min(parseInt(limit as string, 10), 50);
+
+ console.log(`\n[TOP PODCASTS] Request (limit: ${podcastLimit})`);
+
+ // Simple iTunes search - same as the working search bar!
+ const itunesResponse = await axios.get(
+ "https://itunes.apple.com/search",
+ {
+ params: {
+ term: "podcast",
+ media: "podcast",
+ entity: "podcast",
+ limit: podcastLimit,
+ },
+ timeout: 5000,
+ }
+ );
+
+ const podcasts = itunesResponse.data.results.map((podcast: any) => ({
+ id: podcast.collectionId.toString(),
+ title: podcast.collectionName,
+ author: podcast.artistName,
+ coverUrl: podcast.artworkUrl600 || podcast.artworkUrl100,
+ feedUrl: podcast.feedUrl,
+ genres: podcast.genres || [],
+ episodeCount: podcast.trackCount || 0,
+ itunesId: podcast.collectionId,
+ isExternal: true,
+ }));
+
+ console.log(` Found ${podcasts.length} podcasts`);
+ res.json(podcasts);
+ } catch (error: any) {
+ console.error("Error fetching top podcasts:", error);
+ res.status(500).json({
+ error: "Failed to fetch top podcasts",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/discover/genres
+ * Get podcasts by specific genres/topics - using simple iTunes search like the search bar
+ */
+router.get("/discover/genres", async (req, res) => {
+ try {
+ const { genres } = req.query; // Comma-separated genre IDs
+
+ console.log(`\n[GENRE PODCASTS] Request (genres: ${genres})`);
+
+ if (!genres || typeof genres !== "string") {
+ return res.status(400).json({
+ error: "genres parameter required (comma-separated genre IDs)",
+ });
+ }
+
+ const genreIds = genres.split(",").map((id) => parseInt(id.trim(), 10));
+
+ // Map genre IDs to search terms - same approach as the working search!
+ const genreSearchTerms: { [key: number]: string } = {
+ 1303: "comedy podcast", // Comedy
+ 1324: "society culture podcast", // Society & Culture
+ 1489: "news podcast", // News
+ 1488: "true crime podcast", // True Crime
+ 1321: "business podcast", // Business
+ 1545: "sports podcast", // Sports
+ 1502: "gaming hobbies podcast", // Leisure (Gaming & Hobbies)
+ };
+
+ // Fetch podcasts for each genre using simple iTunes search - PARALLEL execution
+ const genreFetchPromises = genreIds.map(async (genreId) => {
+ const searchTerm = genreSearchTerms[genreId] || "podcast";
+ console.log(` Searching for "${searchTerm}"...`);
+
+ try {
+ // Simple iTunes search - same as the working search bar!
+ const itunesResponse = await axios.get(
+ "https://itunes.apple.com/search",
+ {
+ params: {
+ term: searchTerm,
+ media: "podcast",
+ entity: "podcast",
+ limit: 10,
+ },
+ timeout: 5000,
+ }
+ );
+
+ const podcasts = itunesResponse.data.results.map(
+ (podcast: any) => ({
+ id: podcast.collectionId.toString(),
+ title: podcast.collectionName,
+ author: podcast.artistName,
+ coverUrl:
+ podcast.artworkUrl600 || podcast.artworkUrl100,
+ feedUrl: podcast.feedUrl,
+ genres: podcast.genres || [],
+ episodeCount: podcast.trackCount || 0,
+ itunesId: podcast.collectionId,
+ isExternal: true,
+ })
+ );
+
+ console.log(
+ ` Found ${podcasts.length} podcasts for genre ${genreId}`
+ );
+ return { genreId, podcasts };
+ } catch (error: any) {
+ console.error(
+ ` Error searching for ${searchTerm}:`,
+ error.message
+ );
+ return { genreId, podcasts: [] };
+ }
+ });
+
+ // Wait for all genre searches to complete in parallel
+ const genreResults = await Promise.all(genreFetchPromises);
+
+ // Convert array of results to object keyed by genreId
+ const results: any = {};
+ for (const { genreId, podcasts } of genreResults) {
+ results[genreId] = podcasts;
+ }
+
+ console.log(
+ ` Fetched podcasts for ${genreIds.length} genres (parallel)`
+ );
+ res.json(results);
+ } catch (error: any) {
+ console.error("Error fetching genre podcasts:", error);
+ res.status(500).json({
+ error: "Failed to fetch genre podcasts",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/discover/genre/:genreId
+ * Get paginated podcasts for a specific genre with offset support
+ */
+router.get("/discover/genre/:genreId", async (req, res) => {
+ try {
+ const { genreId } = req.params;
+ const { limit = "20", offset = "0" } = req.query;
+
+ const podcastLimit = Math.min(parseInt(limit as string, 10), 50);
+ const podcastOffset = parseInt(offset as string, 10);
+
+ console.log(
+ `\n[GENRE PAGINATED] Request (genre: ${genreId}, limit: ${podcastLimit}, offset: ${podcastOffset})`
+ );
+
+ // Map genre IDs to search terms
+ const genreSearchTerms: { [key: string]: string } = {
+ "1303": "comedy podcast",
+ "1324": "society culture podcast",
+ "1489": "news podcast",
+ "1488": "true crime podcast",
+ "1321": "business podcast",
+ "1545": "sports podcast",
+ "1502": "gaming hobbies podcast",
+ };
+
+ const searchTerm = genreSearchTerms[genreId] || "podcast";
+ console.log(
+ ` Searching for "${searchTerm}" (offset: ${podcastOffset})...`
+ );
+
+ // iTunes API doesn't support offset directly, so we request more and slice
+ // This is a limitation but works for reasonable pagination
+ const totalToFetch = podcastOffset + podcastLimit;
+
+ const itunesResponse = await axios.get(
+ "https://itunes.apple.com/search",
+ {
+ params: {
+ term: searchTerm,
+ media: "podcast",
+ entity: "podcast",
+ limit: Math.min(totalToFetch, 200), // iTunes max is 200
+ },
+ timeout: 5000,
+ }
+ );
+
+ const allPodcasts = itunesResponse.data.results.map((podcast: any) => ({
+ id: podcast.collectionId.toString(),
+ title: podcast.collectionName,
+ author: podcast.artistName,
+ coverUrl: podcast.artworkUrl600 || podcast.artworkUrl100,
+ feedUrl: podcast.feedUrl,
+ genres: podcast.genres || [],
+ episodeCount: podcast.trackCount || 0,
+ itunesId: podcast.collectionId,
+ isExternal: true,
+ }));
+
+ // Slice for pagination
+ const podcasts = allPodcasts.slice(
+ podcastOffset,
+ podcastOffset + podcastLimit
+ );
+
+ console.log(
+ ` Found ${podcasts.length} podcasts (total available: ${allPodcasts.length})`
+ );
+ res.json(podcasts);
+ } catch (error: any) {
+ console.error("Error fetching paginated genre podcasts:", error);
+ res.status(500).json({
+ error: "Failed to fetch podcasts",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/preview/:itunesId
+ * Preview a podcast by iTunes ID (for discovery, before subscribing)
+ * Returns basic podcast info without requiring a subscription
+ */
+router.get("/preview/:itunesId", async (req, res) => {
+ try {
+ const { itunesId } = req.params;
+
+ console.log(`\n [PODCAST PREVIEW] iTunes ID: ${itunesId}`);
+
+ // Try to fetch from iTunes API
+ const itunesResponse = await axios.get(
+ "https://itunes.apple.com/lookup",
+ {
+ params: {
+ id: itunesId,
+ entity: "podcast",
+ },
+ timeout: 5000,
+ }
+ );
+
+ if (
+ !itunesResponse.data.results ||
+ itunesResponse.data.results.length === 0
+ ) {
+ return res.status(404).json({ error: "Podcast not found" });
+ }
+
+ const podcastData = itunesResponse.data.results[0];
+
+ // Check if user is already subscribed
+ const existingPodcast = await prisma.podcast.findFirst({
+ where: {
+ OR: [{ id: itunesId }, { feedUrl: podcastData.feedUrl }],
+ },
+ });
+
+ let isSubscribed = false;
+ if (existingPodcast) {
+ const subscription = await prisma.podcastSubscription.findUnique({
+ where: {
+ userId_podcastId: {
+ userId: req.user!.id,
+ podcastId: existingPodcast.id,
+ },
+ },
+ });
+ isSubscribed = !!subscription;
+ }
+
+ // Fetch description and episodes from RSS feed (iTunes API doesn't provide them)
+ let description = "";
+ let previewEpisodes: any[] = [];
+ if (podcastData.feedUrl) {
+ try {
+ const feedData = await rssParserService.parseFeed(
+ podcastData.feedUrl
+ );
+ description =
+ feedData.description || feedData.itunes?.summary || "";
+
+ // Get first 3 episodes for preview
+ previewEpisodes = (feedData.episodes || [])
+ .slice(0, 3)
+ .map((episode: any) => ({
+ title: episode.title,
+ publishedAt: episode.publishedAt,
+ duration: episode.duration || 0,
+ }));
+
+ console.log(
+ ` [PODCAST PREVIEW] Fetched description (${description.length} chars) and ${previewEpisodes.length} preview episodes`
+ );
+ } catch (error) {
+ console.warn(` Failed to fetch RSS feed for preview:`, error);
+ // Continue without description and episodes
+ }
+ }
+
+ res.json({
+ itunesId: podcastData.collectionId.toString(),
+ title: podcastData.collectionName,
+ author: podcastData.artistName,
+ description: description,
+ coverUrl: podcastData.artworkUrl600 || podcastData.artworkUrl100,
+ feedUrl: podcastData.feedUrl,
+ genres: podcastData.genres || [],
+ episodeCount: podcastData.trackCount || 0,
+ previewEpisodes: previewEpisodes,
+ isSubscribed,
+ subscribedPodcastId: isSubscribed ? existingPodcast!.id : null,
+ });
+ } catch (error: any) {
+ console.error("Error previewing podcast:", error);
+ res.status(500).json({
+ error: "Failed to preview podcast",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/:id
+ * Get a specific podcast with full details and episodes
+ * Requires user to be subscribed
+ */
+router.get("/:id", async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ // Check if user is subscribed
+ const subscription = await prisma.podcastSubscription.findUnique({
+ where: {
+ userId_podcastId: {
+ userId: req.user!.id,
+ podcastId: id,
+ },
+ },
+ });
+
+ if (!subscription) {
+ return res
+ .status(404)
+ .json({ error: "Podcast not found or not subscribed" });
+ }
+
+ const podcast = await prisma.podcast.findUnique({
+ where: { id },
+ include: {
+ episodes: {
+ orderBy: { publishedAt: "desc" },
+ include: {
+ progress: {
+ where: { userId: req.user!.id },
+ },
+ downloads: {
+ where: { userId: req.user!.id },
+ },
+ },
+ },
+ },
+ });
+
+ if (!podcast) {
+ return res.status(404).json({ error: "Podcast not found" });
+ }
+
+ const episodesWithProgress = podcast.episodes.map((episode) => ({
+ id: episode.id,
+ title: episode.title,
+ description: episode.description,
+ duration: episode.duration,
+ publishedAt: episode.publishedAt,
+ episodeNumber: episode.episodeNumber,
+ season: episode.season,
+ imageUrl: episode.imageUrl,
+ isDownloaded: episode.downloads.length > 0,
+ progress: episode.progress[0]
+ ? {
+ currentTime: episode.progress[0].currentTime,
+ progress:
+ episode.progress[0].duration > 0
+ ? (episode.progress[0].currentTime /
+ episode.progress[0].duration) *
+ 100
+ : 0,
+ isFinished: episode.progress[0].isFinished,
+ lastPlayedAt: episode.progress[0].lastPlayedAt,
+ }
+ : null,
+ }));
+
+ res.json({
+ id: podcast.id,
+ title: podcast.title,
+ author: podcast.author,
+ description: podcast.description,
+ coverUrl: podcast.imageUrl,
+ feedUrl: podcast.feedUrl,
+ genres: [], // Podcast genres not yet stored in database
+ autoDownloadEpisodes: false,
+ episodes: episodesWithProgress,
+ isSubscribed: true,
+ });
+ } catch (error: any) {
+ console.error("Error fetching podcast:", error);
+ res.status(500).json({
+ error: "Failed to fetch podcast",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * POST /podcasts/subscribe
+ * Subscribe to a podcast by RSS feed URL or iTunes ID
+ */
+router.post("/subscribe", async (req, res) => {
+ try {
+ const { feedUrl, itunesId } = req.body;
+
+ if (!feedUrl && !itunesId) {
+ return res
+ .status(400)
+ .json({ error: "feedUrl or itunesId is required" });
+ }
+
+ console.log(
+ `\n [PODCAST] Subscribe request from ${req.user!.username}`
+ );
+ console.log(` Feed URL: ${feedUrl || "N/A"}`);
+ console.log(` iTunes ID: ${itunesId || "N/A"}`);
+
+ let finalFeedUrl = feedUrl;
+
+ // If only iTunes ID provided, fetch feed URL from iTunes API
+ if (!finalFeedUrl && itunesId) {
+ console.log(` Looking up feed URL from iTunes...`);
+ const itunesResponse = await axios.get(
+ "https://itunes.apple.com/lookup",
+ {
+ params: { id: itunesId, entity: "podcast" },
+ }
+ );
+
+ if (
+ itunesResponse.data.resultCount === 0 ||
+ !itunesResponse.data.results[0].feedUrl
+ ) {
+ return res
+ .status(404)
+ .json({ error: "Podcast not found in iTunes" });
+ }
+
+ finalFeedUrl = itunesResponse.data.results[0].feedUrl;
+ console.log(` Found feed URL: ${finalFeedUrl}`);
+ }
+
+ // Check if podcast already exists in database
+ let podcast = await prisma.podcast.findUnique({
+ where: { feedUrl: finalFeedUrl },
+ });
+
+ if (podcast) {
+ console.log(` Podcast exists in database: ${podcast.title}`);
+
+ // Check if user is already subscribed
+ const existingSubscription =
+ await prisma.podcastSubscription.findUnique({
+ where: {
+ userId_podcastId: {
+ userId: req.user!.id,
+ podcastId: podcast.id,
+ },
+ },
+ });
+
+ if (existingSubscription) {
+ console.log(` User already subscribed`);
+ return res.json({
+ success: true,
+ podcast: {
+ id: podcast.id,
+ title: podcast.title,
+ },
+ message: "Already subscribed",
+ });
+ }
+
+ // Subscribe user to existing podcast
+ await prisma.podcastSubscription.create({
+ data: {
+ userId: req.user!.id,
+ podcastId: podcast.id,
+ },
+ });
+
+ console.log(` User subscribed to existing podcast`);
+ return res.json({
+ success: true,
+ podcast: {
+ id: podcast.id,
+ title: podcast.title,
+ },
+ message: "Subscribed successfully",
+ });
+ }
+
+ // Parse RSS feed to get podcast and episodes
+ console.log(` Parsing RSS feed...`);
+ const { podcast: podcastData, episodes } =
+ await rssParserService.parseFeed(finalFeedUrl);
+
+ // Create podcast in database
+ console.log(` Saving podcast to database...`);
+ const finalItunesId = itunesId || podcastData.itunesId;
+ console.log(` iTunes ID to save: ${finalItunesId || "NONE"}`);
+
+ podcast = await prisma.podcast.create({
+ data: {
+ feedUrl: finalFeedUrl,
+ title: podcastData.title,
+ author: podcastData.author,
+ description: podcastData.description,
+ imageUrl: podcastData.imageUrl,
+ itunesId: finalItunesId,
+ language: podcastData.language,
+ explicit: podcastData.explicit || false,
+ episodeCount: episodes.length,
+ },
+ });
+
+ console.log(` Podcast created: ${podcast.id}`);
+ console.log(` iTunes ID saved: ${podcast.itunesId || "NONE"}`);
+
+ // Save episodes
+ console.log(` Saving ${episodes.length} episodes...`);
+ await prisma.podcastEpisode.createMany({
+ data: episodes.map((ep) => ({
+ podcastId: podcast!.id,
+ guid: ep.guid,
+ title: ep.title,
+ description: ep.description,
+ audioUrl: ep.audioUrl,
+ duration: ep.duration,
+ publishedAt: ep.publishedAt,
+ episodeNumber: ep.episodeNumber,
+ season: ep.season,
+ imageUrl: ep.imageUrl,
+ fileSize: ep.fileSize,
+ mimeType: ep.mimeType,
+ })),
+ skipDuplicates: true,
+ });
+
+ console.log(` Episodes saved`);
+
+ // Subscribe user
+ await prisma.podcastSubscription.create({
+ data: {
+ userId: req.user!.id,
+ podcastId: podcast.id,
+ },
+ });
+
+ console.log(` User subscribed successfully`);
+
+ res.json({
+ success: true,
+ podcast: {
+ id: podcast.id,
+ title: podcast.title,
+ },
+ message: "Subscribed successfully",
+ });
+ } catch (error: any) {
+ console.error("Error subscribing to podcast:", error);
+ res.status(500).json({
+ error: "Failed to subscribe to podcast",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * DELETE /podcasts/:id/unsubscribe
+ * Unsubscribe from a podcast
+ */
+router.delete("/:id/unsubscribe", async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ console.log(`\n[PODCAST] Unsubscribe request`);
+ console.log(` User: ${req.user!.username}`);
+ console.log(` Podcast ID: ${id}`);
+
+ // Delete subscription
+ const deleted = await prisma.podcastSubscription.deleteMany({
+ where: {
+ userId: req.user!.id,
+ podcastId: id,
+ },
+ });
+
+ if (deleted.count === 0) {
+ return res
+ .status(404)
+ .json({ error: "Not subscribed to this podcast" });
+ }
+
+ // Also delete user's progress for this podcast
+ await prisma.podcastProgress.deleteMany({
+ where: {
+ userId: req.user!.id,
+ episode: {
+ podcastId: id,
+ },
+ },
+ });
+
+ // Also delete any downloaded episodes
+ await prisma.podcastDownload.deleteMany({
+ where: {
+ userId: req.user!.id,
+ episode: {
+ podcastId: id,
+ },
+ },
+ });
+
+ console.log(` Unsubscribed successfully`);
+
+ res.json({
+ success: true,
+ message: "Unsubscribed successfully",
+ });
+ } catch (error: any) {
+ console.error("Error unsubscribing from podcast:", error);
+ res.status(500).json({
+ error: "Failed to unsubscribe",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/:id/refresh
+ * Manually refresh podcast feed to check for new episodes
+ */
+router.get("/:id/refresh", async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ console.log(`\n [PODCAST] Refresh request`);
+ console.log(` Podcast ID: ${id}`);
+
+ const podcast = await prisma.podcast.findUnique({
+ where: { id },
+ });
+
+ if (!podcast) {
+ return res.status(404).json({ error: "Podcast not found" });
+ }
+
+ // Parse RSS feed
+ console.log(` Parsing RSS feed...`);
+ const { podcast: podcastData, episodes } =
+ await rssParserService.parseFeed(podcast.feedUrl);
+
+ // Update podcast metadata
+ await prisma.podcast.update({
+ where: { id },
+ data: {
+ title: podcastData.title,
+ author: podcastData.author,
+ description: podcastData.description,
+ imageUrl: podcastData.imageUrl,
+ language: podcastData.language,
+ explicit: podcastData.explicit || false,
+ episodeCount: episodes.length,
+ lastRefreshed: new Date(),
+ },
+ });
+
+ // Add new episodes (skip duplicates)
+ let newEpisodesCount = 0;
+ for (const ep of episodes) {
+ const existing = await prisma.podcastEpisode.findUnique({
+ where: {
+ podcastId_guid: {
+ podcastId: id,
+ guid: ep.guid,
+ },
+ },
+ });
+
+ if (!existing) {
+ await prisma.podcastEpisode.create({
+ data: {
+ podcastId: id,
+ guid: ep.guid,
+ title: ep.title,
+ description: ep.description,
+ audioUrl: ep.audioUrl,
+ duration: ep.duration,
+ publishedAt: ep.publishedAt,
+ episodeNumber: ep.episodeNumber,
+ season: ep.season,
+ imageUrl: ep.imageUrl,
+ fileSize: ep.fileSize,
+ mimeType: ep.mimeType,
+ },
+ });
+ newEpisodesCount++;
+ }
+ }
+
+ console.log(
+ ` Refresh complete. ${newEpisodesCount} new episodes added.`
+ );
+
+ res.json({
+ success: true,
+ newEpisodesCount,
+ totalEpisodes: episodes.length,
+ message: `Found ${newEpisodesCount} new episodes`,
+ });
+ } catch (error: any) {
+ console.error("Error refreshing podcast:", error);
+ res.status(500).json({
+ error: "Failed to refresh podcast",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/:podcastId/episodes/:episodeId/cache-status
+ * Check if a podcast episode is cached locally
+ * Used by frontend to know when it's safe to reload for seeking
+ * Also returns download progress if downloading
+ */
+router.get("/:podcastId/episodes/:episodeId/cache-status", async (req, res) => {
+ try {
+ const { episodeId } = req.params;
+
+ const { getCachedFilePath, isDownloading, getDownloadProgress } =
+ await import("../services/podcastDownload");
+
+ const cachedPath = await getCachedFilePath(episodeId);
+ const downloading = isDownloading(episodeId);
+ const progress = getDownloadProgress(episodeId);
+
+ res.json({
+ episodeId,
+ cached: !!cachedPath,
+ downloading,
+ downloadProgress: progress?.progress ?? null, // 0-100 or null
+ path: cachedPath ? true : false, // Don't expose actual path
+ });
+ } catch (error: any) {
+ console.error("[PODCAST] Cache status check failed:", error);
+ res.status(500).json({ error: "Failed to check cache status" });
+ }
+});
+
+/**
+ * GET /podcasts/:podcastId/episodes/:episodeId/stream
+ * Stream a podcast episode (from local cache or RSS URL)
+ * Auto-caches episodes in background for better seeking support
+ */
+router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
+ try {
+ const { podcastId, episodeId } = req.params;
+ const userId = req.user?.id;
+ const podcastDebug = process.env.PODCAST_DEBUG === "1";
+
+ console.log(`\n [PODCAST STREAM] Request:`);
+ console.log(` Podcast ID: ${podcastId}`);
+ console.log(` Episode ID: ${episodeId}`);
+ if (podcastDebug) {
+ console.log(` Range: ${req.headers.range || "none"}`);
+ console.log(` UA: ${req.headers["user-agent"] || "unknown"}`);
+ }
+
+ const episode = await prisma.podcastEpisode.findUnique({
+ where: { id: episodeId },
+ });
+
+ if (!episode) {
+ return res.status(404).json({ error: "Episode not found" });
+ }
+
+ if (podcastDebug) {
+ console.log(` Episode DB: title="${episode.title}"`);
+ console.log(` Episode DB: guid="${episode.guid}"`);
+ console.log(` Episode DB: audioUrl="${episode.audioUrl}"`);
+ console.log(` Episode DB: mimeType="${episode.mimeType || "unknown"}" fileSize=${episode.fileSize || 0}`);
+ }
+
+ const range = req.headers.range;
+
+ // Import podcast download service
+ const { getCachedFilePath, downloadInBackground, isDownloading } =
+ await import("../services/podcastDownload");
+
+ // Check if episode is cached locally (with full range support)
+ const cachedPath = await getCachedFilePath(episodeId);
+
+ if (cachedPath) {
+ console.log(` Streaming from cache: ${cachedPath}`);
+ try {
+ const stats = await fs.promises.stat(cachedPath);
+ const fileSize = stats.size;
+ if (podcastDebug) {
+ console.log(` Cache file size: ${fileSize}`);
+ }
+
+ if (fileSize === 0) {
+ throw new Error("Cached file is empty");
+ }
+
+ if (range) {
+ const [startStr, endStr] = range
+ .replace(/bytes=/, "")
+ .split("-");
+ const start = parseInt(startStr, 10);
+ const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
+
+ // Validate range bounds
+ if (start >= fileSize) {
+ console.log(
+ ` Range start ${start} >= file size ${fileSize}, clamping to EOF`
+ );
+ // Browsers can occasionally request a range start beyond EOF during media seeking.
+ // Returning 416 can cause some clients to stall; instead clamp to a small window near EOF and serve 206.
+ // NOTE: Serving only the last byte is not a valid decodable audio chunk for many formats/clients.
+ const clampWindowBytes = 1024 * 1024; // 1MB window near EOF
+ const clampedStart = Math.max(0, fileSize - clampWindowBytes);
+ res.writeHead(206, {
+ "Content-Range": `bytes ${clampedStart}-${fileSize - 1}/${fileSize}`,
+ "Accept-Ranges": "bytes",
+ "Content-Length": fileSize - clampedStart,
+ "Content-Type": episode.mimeType || "audio/mpeg",
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin": req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ });
+ const fileStream = fs.createReadStream(cachedPath, {
+ start: clampedStart,
+ end: fileSize - 1,
+ });
+ // Clean up file stream when client disconnects
+ res.on("close", () => {
+ if (!fileStream.destroyed) {
+ fileStream.destroy();
+ }
+ });
+ fileStream.pipe(res);
+ fileStream.on("error", (err) => {
+ console.error(" Cache stream error:", err);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: "Failed to stream episode",
+ });
+ } else {
+ res.end();
+ }
+ });
+ return; // Exit after starting clamped cache stream
+ }
+
+ const validEnd = Math.min(end, fileSize - 1);
+ const chunkSize = validEnd - start + 1;
+
+ console.log(
+ ` Serving range: bytes ${start}-${validEnd}/${fileSize}`
+ );
+
+ res.writeHead(206, {
+ "Content-Range": `bytes ${start}-${validEnd}/${fileSize}`,
+ "Accept-Ranges": "bytes",
+ "Content-Length": chunkSize,
+ "Content-Type": episode.mimeType || "audio/mpeg",
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin":
+ req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ });
+
+ const fileStream = fs.createReadStream(cachedPath, {
+ start,
+ end: validEnd,
+ });
+ // Clean up file stream when client disconnects
+ res.on("close", () => {
+ if (!fileStream.destroyed) {
+ fileStream.destroy();
+ }
+ });
+ fileStream.pipe(res);
+ fileStream.on("error", (err) => {
+ console.error(" Cache stream error:", err);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: "Failed to stream episode",
+ });
+ } else {
+ res.end();
+ }
+ });
+ return; // CRITICAL: Exit after starting cache stream
+ }
+
+ // No range - serve entire file
+ console.log(` Serving full file: ${fileSize} bytes`);
+ res.writeHead(200, {
+ "Content-Type": episode.mimeType || "audio/mpeg",
+ "Content-Length": fileSize,
+ "Accept-Ranges": "bytes",
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin": req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ });
+
+ const fileStream = fs.createReadStream(cachedPath);
+ // Clean up file stream when client disconnects
+ res.on("close", () => {
+ if (!fileStream.destroyed) {
+ fileStream.destroy();
+ }
+ });
+ fileStream.pipe(res);
+ fileStream.on("error", (err) => {
+ console.error(" Cache stream error:", err);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: "Failed to stream episode",
+ });
+ } else {
+ res.end();
+ }
+ });
+ return; // CRITICAL: Exit after starting cache stream
+ } catch (err: any) {
+ console.error(
+ " Failed to stream from cache, falling back to RSS:",
+ err.message
+ );
+ // Fall through to RSS streaming only if cache fails
+ }
+ }
+
+ // Not cached yet - trigger background download while streaming from RSS
+ if (userId && !isDownloading(episodeId)) {
+ console.log(` Triggering background download for caching`);
+ downloadInBackground(episodeId, episode.audioUrl, userId);
+ }
+
+ // Stream from RSS URL
+ console.log(` Streaming from RSS: ${episode.audioUrl}`);
+
+ // Get file size first for proper range handling
+ let fileSize = episode.fileSize;
+ if (!fileSize) {
+ try {
+ const headResponse = await axios.head(episode.audioUrl);
+ fileSize = parseInt(
+ headResponse.headers["content-length"] || "0"
+ );
+ if (Number.isFinite(fileSize) && fileSize > 0) {
+ await prisma.podcastEpisode.update({
+ where: { id: episode.id },
+ data: { fileSize },
+ });
+ }
+ } catch (err) {
+ console.warn(" Could not get file size via HEAD request");
+ }
+ }
+
+ if (range && fileSize) {
+ // Parse range header (format: bytes=start-end)
+ const parts = range.replace(/bytes=/, "").split("-");
+ const start = parseInt(parts[0], 10);
+ const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
+ const chunkSize = end - start + 1;
+
+ console.log(` Range request: bytes=${start}-${end}/${fileSize}`);
+
+ try {
+ // Try range request first
+ const response = await axios.get(episode.audioUrl, {
+ headers: { Range: `bytes=${start}-${end}` },
+ responseType: "stream",
+ validateStatus: (status) =>
+ status === 206 || status === 200,
+ timeout: 30000,
+ });
+
+ // Send 206 Partial Content with proper range
+ res.writeHead(206, {
+ "Content-Range": `bytes ${start}-${end}/${fileSize}`,
+ "Accept-Ranges": "bytes",
+ "Content-Length": chunkSize,
+ "Content-Type": episode.mimeType || "audio/mpeg",
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin": req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ });
+
+ // Clean up axios stream when client disconnects
+ res.on("close", () => {
+ if (!response.data.destroyed) {
+ response.data.destroy();
+ }
+ });
+ response.data.pipe(res);
+ return;
+ } catch (rangeError: any) {
+ // 416 = Range Not Satisfiable - many podcast CDNs don't support range requests
+ // Fall back to streaming the full file and let the browser handle seeking
+ console.log(
+ ` Range request failed (${
+ rangeError.response?.status || rangeError.message
+ }), falling back to full stream`
+ );
+
+ // Stream full file instead - browser will handle seeking locally
+ const response = await axios.get(episode.audioUrl, {
+ responseType: "stream",
+ timeout: 60000,
+ });
+
+ const contentLength = response.headers["content-length"];
+
+ res.writeHead(200, {
+ "Content-Type": episode.mimeType || "audio/mpeg",
+ "Accept-Ranges": "bytes",
+ ...(contentLength && { "Content-Length": contentLength }),
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin": req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ });
+
+ // Clean up axios stream when client disconnects
+ res.on("close", () => {
+ if (!response.data.destroyed) {
+ response.data.destroy();
+ }
+ });
+ response.data.pipe(res);
+ return;
+ }
+ } else {
+ // No range request - stream entire file
+ console.log(` Streaming full file`);
+
+ const response = await axios.get(episode.audioUrl, {
+ responseType: "stream",
+ });
+
+ const contentLength = response.headers["content-length"];
+
+ res.writeHead(200, {
+ "Content-Type": episode.mimeType || "audio/mpeg",
+ "Accept-Ranges": "bytes",
+ ...(contentLength && { "Content-Length": contentLength }),
+ "Cache-Control": "public, max-age=3600",
+ "Access-Control-Allow-Origin": req.headers.origin || "*",
+ "Access-Control-Allow-Credentials": "true",
+ });
+
+ // Clean up axios stream when client disconnects
+ res.on("close", () => {
+ if (!response.data.destroyed) {
+ response.data.destroy();
+ }
+ });
+ response.data.pipe(res);
+ }
+ } catch (error: any) {
+ console.error("\n [PODCAST STREAM] Error:", error.message);
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: "Failed to stream episode",
+ message: error.message,
+ });
+ }
+ }
+});
+
+/**
+ * POST /podcasts/:podcastId/episodes/:episodeId/progress
+ * Update playback progress for a podcast episode
+ */
+router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
+ try {
+ const { podcastId, episodeId } = req.params;
+ const { currentTime, duration, isFinished } = req.body;
+
+ console.log(`\n [PODCAST PROGRESS] Update:`);
+ console.log(` User: ${req.user!.username}`);
+ console.log(` Episode ID: ${episodeId}`);
+ console.log(` Current Time: ${currentTime}s`);
+ console.log(` Duration: ${duration}s`);
+ console.log(` Finished: ${isFinished}`);
+
+ const progress = await prisma.podcastProgress.upsert({
+ where: {
+ userId_episodeId: {
+ userId: req.user!.id,
+ episodeId: episodeId,
+ },
+ },
+ create: {
+ userId: req.user!.id,
+ episodeId: episodeId,
+ currentTime,
+ duration,
+ isFinished: isFinished || false,
+ },
+ update: {
+ currentTime,
+ duration,
+ isFinished: isFinished || false,
+ lastPlayedAt: new Date(),
+ },
+ });
+
+ console.log(` Progress saved`);
+
+ res.json({
+ success: true,
+ progress: {
+ currentTime: progress.currentTime,
+ progress:
+ progress.duration > 0
+ ? (progress.currentTime / progress.duration) * 100
+ : 0,
+ isFinished: progress.isFinished,
+ },
+ });
+ } catch (error: any) {
+ console.error("Error updating progress:", error);
+ res.status(500).json({
+ error: "Failed to update progress",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * DELETE /podcasts/:podcastId/episodes/:episodeId/progress
+ * Remove/reset progress for a podcast episode
+ */
+router.delete("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
+ try {
+ const { episodeId } = req.params;
+
+ console.log(`\n[PODCAST PROGRESS] Delete:`);
+ console.log(` User: ${req.user!.username}`);
+ console.log(` Episode ID: ${episodeId}`);
+
+ await prisma.podcastProgress.deleteMany({
+ where: {
+ userId: req.user!.id,
+ episodeId: episodeId,
+ },
+ });
+
+ console.log(` Progress removed`);
+
+ res.json({
+ success: true,
+ message: "Progress removed",
+ });
+ } catch (error: any) {
+ console.error("Error removing progress:", error);
+ res.status(500).json({
+ error: "Failed to remove progress",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * GET /podcasts/:id/similar
+ * Get similar podcasts using iTunes Search API (free, no auth required)
+ */
+router.get("/:id/similar", async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const podcast = await prisma.podcast.findUnique({
+ where: { id },
+ });
+
+ if (!podcast) {
+ return res.status(404).json({ error: "Podcast not found" });
+ }
+
+ console.log(`\n [SIMILAR PODCASTS] Request for: ${podcast.title}`);
+
+ try {
+ // Check cache first
+ const cachedRecommendations =
+ await prisma.podcastRecommendation.findMany({
+ where: {
+ podcastId: id,
+ expiresAt: { gt: new Date() },
+ },
+ orderBy: { score: "desc" },
+ take: 10,
+ });
+
+ if (cachedRecommendations.length > 0) {
+ console.log(
+ ` Using ${cachedRecommendations.length} cached recommendations`
+ );
+ return res.json(
+ cachedRecommendations.map((rec) => ({
+ id: rec.recommendedId,
+ title: rec.title,
+ author: rec.author,
+ description: rec.description,
+ coverUrl: rec.coverUrl,
+ episodeCount: rec.episodeCount,
+ feedUrl: rec.feedUrl,
+ itunesId: rec.itunesId,
+ isExternal: true,
+ score: rec.score,
+ }))
+ );
+ }
+
+ // Fetch from iTunes Search API
+ console.log(` Fetching from iTunes Search API...`);
+ const { itunesService } = await import("../services/itunes");
+ const recommendations = await itunesService.getSimilarPodcasts(
+ podcast.title,
+ podcast.description || undefined,
+ podcast.author
+ );
+
+ console.log(` Found ${recommendations.length} similar podcasts`);
+
+ if (recommendations.length > 0) {
+ // Cache recommendations
+ const expiresAt = new Date();
+ expiresAt.setDate(expiresAt.getDate() + 30); // 30 days cache
+
+ await prisma.podcastRecommendation.deleteMany({
+ where: { podcastId: id },
+ });
+
+ await prisma.podcastRecommendation.createMany({
+ data: recommendations.map((rec, index) => ({
+ podcastId: id,
+ recommendedId: rec.collectionId.toString(),
+ title: rec.collectionName,
+ author: rec.artistName,
+ description: "",
+ coverUrl: rec.artworkUrl600 || rec.artworkUrl100,
+ episodeCount: rec.trackCount || 0,
+ feedUrl: rec.feedUrl,
+ itunesId: rec.collectionId.toString(),
+ score: recommendations.length - index,
+ cachedAt: new Date(),
+ expiresAt,
+ })),
+ });
+
+ console.log(
+ ` Cached ${recommendations.length} recommendations`
+ );
+
+ return res.json(
+ recommendations.map((rec, index) => ({
+ id: rec.collectionId.toString(),
+ title: rec.collectionName,
+ author: rec.artistName,
+ description: "",
+ coverUrl: rec.artworkUrl600 || rec.artworkUrl100,
+ episodeCount: rec.trackCount || 0,
+ feedUrl: rec.feedUrl,
+ itunesId: rec.collectionId,
+ isExternal: true,
+ score: recommendations.length - index,
+ }))
+ );
+ }
+ } catch (error: any) {
+ console.warn(" iTunes search failed:", error.message);
+ }
+
+ // No recommendations available
+ console.log(` No recommendations found`);
+ res.json([]);
+ } catch (error: any) {
+ console.error("Error fetching similar podcasts:", error);
+ res.status(500).json({
+ error: "Failed to fetch similar podcasts",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * OPTIONS /podcasts/:id/cover
+ * Handle CORS preflight request for podcast cover images
+ */
+router.options("/:id/cover", (req, res) => {
+ const origin = req.headers.origin || "*";
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+ res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours
+ res.status(204).end();
+});
+
+/**
+ * GET /podcasts/:id/cover
+ * Serve cached podcast cover from local disk
+ */
+router.get("/:id/cover", async (req, res) => {
+ try {
+ const { id } = req.params;
+
+ const podcast = await prisma.podcast.findUnique({
+ where: { id },
+ select: { localCoverPath: true, imageUrl: true },
+ });
+
+ if (!podcast) {
+ return res.status(404).json({ error: "Podcast not found" });
+ }
+
+ // Serve from local disk if cached
+ if (podcast.localCoverPath) {
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=31536000, immutable"
+ );
+ res.setHeader(
+ "Access-Control-Allow-Origin",
+ req.headers.origin || "*"
+ );
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
+ return res.sendFile(podcast.localCoverPath);
+ }
+
+ // Fallback: redirect to original URL
+ if (podcast.imageUrl) {
+ return res.redirect(podcast.imageUrl);
+ }
+
+ res.status(404).json({ error: "Cover not found" });
+ } catch (error: any) {
+ console.error("Error serving podcast cover:", error);
+ res.status(500).json({
+ error: "Failed to serve cover",
+ message: error.message,
+ });
+ }
+});
+
+/**
+ * OPTIONS /podcasts/episodes/:episodeId/cover
+ * Handle CORS preflight request for episode cover images
+ */
+router.options("/episodes/:episodeId/cover", (req, res) => {
+ const origin = req.headers.origin || "*";
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+ res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours
+ res.status(204).end();
+});
+
+/**
+ * GET /podcasts/episodes/:episodeId/cover
+ * Serve cached episode cover from local disk
+ */
+router.get("/episodes/:episodeId/cover", async (req, res) => {
+ try {
+ const { episodeId } = req.params;
+
+ const episode = await prisma.podcastEpisode.findUnique({
+ where: { id: episodeId },
+ select: { localCoverPath: true, imageUrl: true },
+ });
+
+ if (!episode) {
+ return res.status(404).json({ error: "Episode not found" });
+ }
+
+ // Serve from local disk if cached
+ if (episode.localCoverPath) {
+ res.setHeader(
+ "Cache-Control",
+ "public, max-age=31536000, immutable"
+ );
+ res.setHeader(
+ "Access-Control-Allow-Origin",
+ req.headers.origin || "*"
+ );
+ res.setHeader("Access-Control-Allow-Credentials", "true");
+ res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
+ return res.sendFile(episode.localCoverPath);
+ }
+
+ // Fallback: redirect to original URL
+ if (episode.imageUrl) {
+ return res.redirect(episode.imageUrl);
+ }
+
+ res.status(404).json({ error: "Cover not found" });
+ } catch (error: any) {
+ console.error("Error serving episode cover:", error);
+ res.status(500).json({
+ error: "Failed to serve cover",
+ message: error.message,
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/recommendations.ts b/backend/src/routes/recommendations.ts
new file mode 100644
index 0000000..478ec54
--- /dev/null
+++ b/backend/src/routes/recommendations.ts
@@ -0,0 +1,469 @@
+import { Router } from "express";
+import { requireAuth, requireAuthOrToken } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { lastFmService } from "../services/lastfm";
+
+const router = Router();
+
+router.use(requireAuthOrToken);
+
+// GET /recommendations/for-you?limit=10
+router.get("/for-you", async (req, res) => {
+ try {
+ const { limit = "10" } = req.query;
+ const userId = req.user!.id;
+ const limitNum = parseInt(limit as string, 10);
+
+ // Get user's most played artists
+ const recentPlays = await prisma.play.findMany({
+ where: { userId },
+ orderBy: { playedAt: "desc" },
+ take: 50,
+ include: {
+ track: {
+ include: {
+ album: {
+ include: {
+ artist: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // Count plays per artist
+ const artistPlayCounts = new Map<
+ string,
+ { artist: any; count: number }
+ >();
+ for (const play of recentPlays) {
+ const artist = play.track.album.artist;
+ const existing = artistPlayCounts.get(artist.id);
+ if (existing) {
+ existing.count++;
+ } else {
+ artistPlayCounts.set(artist.id, { artist, count: 1 });
+ }
+ }
+
+ // Sort by play count and get top 3 seed artists
+ const topArtists = Array.from(artistPlayCounts.values())
+ .sort((a, b) => b.count - a.count)
+ .slice(0, 3);
+
+ if (topArtists.length === 0) {
+ // No listening history, return empty recommendations
+ return res.json({ artists: [] });
+ }
+
+ // Get similar artists for each top artist
+ const allSimilarArtists = await Promise.all(
+ topArtists.map(async ({ artist }) => {
+ const similar = await prisma.similarArtist.findMany({
+ where: { fromArtistId: artist.id },
+ orderBy: { weight: "desc" },
+ take: 10,
+ include: {
+ toArtist: {
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ heroUrl: true,
+ },
+ },
+ },
+ });
+ return similar.map((s) => s.toArtist);
+ })
+ );
+
+ // Flatten and deduplicate
+ const recommendedArtists = Array.from(
+ new Map(
+ allSimilarArtists.flat().map((artist) => [artist.id, artist])
+ ).values()
+ );
+
+ // Filter out artists user already owns (from native library)
+ const ownedArtists = await prisma.ownedAlbum.findMany({
+ select: { artistId: true },
+ distinct: ["artistId"],
+ });
+ const ownedArtistIds = new Set(ownedArtists.map((a) => a.artistId));
+
+ console.log(
+ `Filtering recommendations: ${ownedArtistIds.size} owned artists to exclude`
+ );
+
+ const newArtists = recommendedArtists.filter(
+ (artist) => !ownedArtistIds.has(artist.id)
+ );
+
+ // Get album counts for recommended artists (from enriched discography)
+ const recommendedArtistIds = newArtists
+ .slice(0, limitNum)
+ .map((a) => a.id);
+ const albumCounts = await prisma.album.groupBy({
+ by: ["artistId"],
+ where: { artistId: { in: recommendedArtistIds } },
+ _count: { rgMbid: true },
+ });
+ const albumCountMap = new Map(
+ albumCounts.map((ac) => [ac.artistId, ac._count.rgMbid])
+ );
+
+ // ========== CACHE-ONLY IMAGE LOOKUP FOR RECOMMENDATIONS ==========
+ // Only use cached data (DB heroUrl or Redis cache) - no API calls during page loads
+ // Background enrichment worker will populate cache over time
+ const { redisClient } = await import("../utils/redis");
+
+ // Get all cached images in a single Redis call for efficiency
+ const artistsToCheck = newArtists.slice(0, limitNum);
+ const cacheKeys = artistsToCheck
+ .filter(a => !a.heroUrl)
+ .map(a => `hero:${a.id}`);
+
+ let cachedImages: (string | null)[] = [];
+ if (cacheKeys.length > 0) {
+ try {
+ cachedImages = await redisClient.mGet(cacheKeys);
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+ }
+
+ // Build a map from cache results
+ const cachedImageMap = new Map();
+ let cacheIndex = 0;
+ for (const artist of artistsToCheck) {
+ if (!artist.heroUrl) {
+ const cached = cachedImages[cacheIndex];
+ if (cached && cached !== "NOT_FOUND") {
+ cachedImageMap.set(artist.id, cached);
+ }
+ cacheIndex++;
+ }
+ }
+
+ const artistsWithMetadata = artistsToCheck.map((artist) => {
+ // Use DB heroUrl first, then Redis cache, otherwise null
+ const coverArt = artist.heroUrl || cachedImageMap.get(artist.id) || null;
+
+ return {
+ ...artist,
+ coverArt,
+ albumCount: albumCountMap.get(artist.id) || 0,
+ };
+ });
+
+ console.log(
+ `Recommendations: Found ${artistsWithMetadata.length} new artists`
+ );
+ artistsWithMetadata.forEach((a) => {
+ console.log(
+ ` ${a.name}: coverArt=${a.coverArt ? "YES" : "NO"}, albums=${
+ a.albumCount
+ }`
+ );
+ });
+
+ res.json({ artists: artistsWithMetadata });
+ } catch (error) {
+ console.error("Get recommendations for you error:", error);
+ res.status(500).json({ error: "Failed to get recommendations" });
+ }
+});
+
+// GET /recommendations?seedArtistId=
+router.get("/", async (req, res) => {
+ try {
+ const { seedArtistId } = req.query;
+
+ if (!seedArtistId) {
+ return res.status(400).json({ error: "seedArtistId required" });
+ }
+
+ // Get seed artist
+ const seedArtist = await prisma.artist.findUnique({
+ where: { id: seedArtistId as string },
+ });
+
+ if (!seedArtist) {
+ return res.status(404).json({ error: "Artist not found" });
+ }
+
+ // Get similar artists from database
+ const similarArtists = await prisma.similarArtist.findMany({
+ where: { fromArtistId: seedArtistId as string },
+ orderBy: { weight: "desc" },
+ take: 20,
+ });
+
+ // Fetch full artist details for each similar artist
+ const recommendations = await Promise.all(
+ similarArtists.map(async (similar) => {
+ const artist = await prisma.artist.findUnique({
+ where: { id: similar.toArtistId },
+ });
+
+ const albums = await prisma.album.findMany({
+ where: { artistId: similar.toArtistId },
+ orderBy: { year: "desc" },
+ take: 3,
+ });
+
+ const ownedAlbums = await prisma.ownedAlbum.findMany({
+ where: { artistId: similar.toArtistId },
+ });
+
+ const ownedRgMbids = new Set(ownedAlbums.map((o) => o.rgMbid));
+
+ return {
+ artist: {
+ id: artist?.id,
+ mbid: artist?.mbid,
+ name: artist?.name,
+ heroUrl: artist?.heroUrl,
+ },
+ similarity: similar.weight,
+ topAlbums: albums.map((album) => ({
+ ...album,
+ owned: ownedRgMbids.has(album.rgMbid),
+ })),
+ };
+ })
+ );
+
+ res.json({
+ seedArtist: {
+ id: seedArtist.id,
+ name: seedArtist.name,
+ },
+ recommendations,
+ });
+ } catch (error) {
+ console.error("Get recommendations error:", error);
+ res.status(500).json({ error: "Failed to get recommendations" });
+ }
+});
+
+// GET /recommendations/albums?seedAlbumId=
+router.get("/albums", async (req, res) => {
+ try {
+ const { seedAlbumId } = req.query;
+
+ if (!seedAlbumId) {
+ return res.status(400).json({ error: "seedAlbumId required" });
+ }
+
+ // Get seed album
+ const seedAlbum = await prisma.album.findUnique({
+ where: { id: seedAlbumId as string },
+ include: {
+ artist: true,
+ tracks: {
+ include: {
+ trackGenres: {
+ include: {
+ genre: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!seedAlbum) {
+ return res.status(404).json({ error: "Album not found" });
+ }
+
+ // Get genre tags from the album's tracks
+ const genreTags = Array.from(
+ new Set(
+ seedAlbum.tracks.flatMap((track) =>
+ track.trackGenres.map((tg) => tg.genre.name)
+ )
+ )
+ );
+
+ // Strategy 1: Get albums from similar artists
+ const similarArtists = await prisma.similarArtist.findMany({
+ where: { fromArtistId: seedAlbum.artistId },
+ orderBy: { weight: "desc" },
+ take: 10,
+ });
+
+ const similarArtistAlbums = await prisma.album.findMany({
+ where: {
+ artistId: { in: similarArtists.map((sa) => sa.toArtistId) },
+ id: { not: seedAlbumId as string }, // Exclude seed album
+ },
+ include: {
+ artist: true,
+ },
+ orderBy: { year: "desc" },
+ take: 15,
+ });
+
+ // Strategy 2: Get albums with matching genres
+ let genreMatchAlbums: any[] = [];
+ if (genreTags.length > 0) {
+ genreMatchAlbums = await prisma.album.findMany({
+ where: {
+ id: { not: seedAlbumId as string },
+ tracks: {
+ some: {
+ trackGenres: {
+ some: {
+ genre: {
+ name: { in: genreTags },
+ },
+ },
+ },
+ },
+ },
+ },
+ include: {
+ artist: true,
+ },
+ take: 10,
+ });
+ }
+
+ // Combine and deduplicate
+ const allAlbums = [...similarArtistAlbums, ...genreMatchAlbums];
+ const uniqueAlbums = Array.from(
+ new Map(allAlbums.map((album) => [album.id, album])).values()
+ );
+
+ // Check ownership
+ const recommendations = await Promise.all(
+ uniqueAlbums.slice(0, 20).map(async (album) => {
+ const ownedAlbums = await prisma.ownedAlbum.findMany({
+ where: { artistId: album.artistId },
+ });
+
+ const ownedRgMbids = new Set(ownedAlbums.map((o) => o.rgMbid));
+
+ return {
+ ...album,
+ owned: ownedRgMbids.has(album.rgMbid),
+ };
+ })
+ );
+
+ res.json({
+ seedAlbum: {
+ id: seedAlbum.id,
+ title: seedAlbum.title,
+ artist: seedAlbum.artist.name,
+ },
+ recommendations,
+ });
+ } catch (error) {
+ console.error("Get album recommendations error:", error);
+ res.status(500).json({
+ error: "Failed to get album recommendations",
+ });
+ }
+});
+
+// GET /recommendations/tracks?seedTrackId=
+router.get("/tracks", async (req, res) => {
+ try {
+ const { seedTrackId } = req.query;
+
+ if (!seedTrackId) {
+ return res.status(400).json({ error: "seedTrackId required" });
+ }
+
+ // Get seed track
+ const seedTrack = await prisma.track.findUnique({
+ where: { id: seedTrackId as string },
+ include: {
+ album: {
+ include: {
+ artist: true,
+ },
+ },
+ },
+ });
+
+ if (!seedTrack) {
+ return res.status(404).json({ error: "Track not found" });
+ }
+
+ // Use Last.fm to get similar tracks
+ const similarTracksFromLastFm = await lastFmService.getSimilarTracks(
+ seedTrack.album.artist.name,
+ seedTrack.title,
+ 20
+ );
+
+ // Try to match similar tracks in our library
+ const recommendations = [];
+
+ for (const lfmTrack of similarTracksFromLastFm) {
+ const matchedTracks = await prisma.track.findMany({
+ where: {
+ title: {
+ contains: lfmTrack.name,
+ mode: "insensitive",
+ },
+ album: {
+ artist: {
+ name: {
+ contains: lfmTrack.artist?.name || "",
+ mode: "insensitive",
+ },
+ },
+ },
+ },
+ include: {
+ album: {
+ include: {
+ artist: true,
+ },
+ },
+ },
+ take: 1,
+ });
+
+ if (matchedTracks.length > 0) {
+ recommendations.push({
+ ...matchedTracks[0],
+ inLibrary: true,
+ similarity: lfmTrack.match || 0,
+ });
+ } else {
+ // Include Last.fm suggestion even if not in library
+ recommendations.push({
+ title: lfmTrack.name,
+ artist: lfmTrack.artist?.name || "Unknown",
+ inLibrary: false,
+ similarity: lfmTrack.match || 0,
+ lastFmUrl: lfmTrack.url,
+ });
+ }
+ }
+
+ res.json({
+ seedTrack: {
+ id: seedTrack.id,
+ title: seedTrack.title,
+ artist: seedTrack.album.artist.name,
+ album: seedTrack.album.title,
+ },
+ recommendations,
+ });
+ } catch (error) {
+ console.error("Get track recommendations error:", error);
+ res.status(500).json({
+ error: "Failed to get track recommendations",
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/releases.ts b/backend/src/routes/releases.ts
new file mode 100644
index 0000000..5dc6e8e
--- /dev/null
+++ b/backend/src/routes/releases.ts
@@ -0,0 +1,259 @@
+/**
+ * Release Radar API
+ *
+ * Provides upcoming and recent releases from:
+ * 1. Lidarr monitored artists (via calendar API)
+ * 2. Similar artists from user's library (Last.fm similar artists)
+ */
+
+import { Router } from "express";
+import { lidarrService, CalendarRelease } from "../services/lidarr";
+import { prisma } from "../utils/db";
+
+const router = Router();
+
+interface ReleaseRadarResponse {
+ upcoming: ReleaseItem[];
+ recent: ReleaseItem[];
+ monitoredArtistCount: number;
+ similarArtistCount: number;
+}
+
+interface ReleaseItem {
+ id: number | string;
+ title: string;
+ artistName: string;
+ artistMbid?: string;
+ albumMbid: string;
+ releaseDate: string;
+ coverUrl: string | null;
+ source: 'lidarr' | 'similar';
+ status: 'upcoming' | 'released' | 'available';
+ inLibrary: boolean;
+ canDownload: boolean;
+}
+
+/**
+ * GET /releases/radar
+ *
+ * Get upcoming and recent releases for the user's monitored artists
+ * and their similar artists.
+ */
+router.get("/radar", async (req, res) => {
+ try {
+ const now = new Date();
+ const daysBack = parseInt(req.query.daysBack as string) || 30;
+ const daysAhead = parseInt(req.query.daysAhead as string) || 90;
+
+ // Calculate date range
+ const startDate = new Date(now);
+ startDate.setDate(startDate.getDate() - daysBack);
+
+ const endDate = new Date(now);
+ endDate.setDate(endDate.getDate() + daysAhead);
+
+ console.log(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`);
+
+ // 1. Get releases from Lidarr calendar (monitored artists)
+ const lidarrReleases = await lidarrService.getCalendar(startDate, endDate);
+
+ // 2. Get monitored artists from Lidarr
+ const monitoredArtists = await lidarrService.getMonitoredArtists();
+ const monitoredMbids = new Set(monitoredArtists.map(a => a.mbid));
+
+ // 3. Get similar artists from user's library that aren't monitored
+ const similarArtists = await prisma.similarArtist.findMany({
+ where: {
+ // Source artist is in the library (has albums)
+ fromArtist: {
+ albums: { some: {} }
+ },
+ // Target artist is NOT in library (no albums)
+ toArtist: {
+ albums: { none: {} }
+ }
+ },
+ select: {
+ toArtist: {
+ select: {
+ id: true,
+ name: true,
+ mbid: true,
+ }
+ },
+ weight: true,
+ },
+ orderBy: { weight: 'desc' },
+ take: 50, // Top 50 similar artists
+ });
+
+ // Filter out any that are already monitored in Lidarr
+ const unmonitoredSimilar = similarArtists.filter(
+ sa => sa.toArtist.mbid && !monitoredMbids.has(sa.toArtist.mbid)
+ );
+
+ console.log(`[Releases] Found ${lidarrReleases.length} Lidarr releases`);
+ console.log(`[Releases] Found ${unmonitoredSimilar.length} unmonitored similar artists`);
+
+ // 4. Get albums in library to check what user already has
+ const libraryAlbums = await prisma.album.findMany({
+ select: {
+ rgMbid: true,
+ }
+ });
+ const libraryAlbumMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
+
+ // 5. Transform Lidarr releases
+ const releases: ReleaseItem[] = lidarrReleases.map(release => {
+ const releaseTime = new Date(release.releaseDate).getTime();
+ const isUpcoming = releaseTime > now.getTime();
+ const inLibrary = release.hasFile || libraryAlbumMbids.has(release.albumMbid);
+
+ return {
+ id: release.id,
+ title: release.title,
+ artistName: release.artistName,
+ artistMbid: release.artistMbid,
+ albumMbid: release.albumMbid,
+ releaseDate: release.releaseDate,
+ coverUrl: release.coverUrl,
+ source: 'lidarr' as const,
+ status: isUpcoming ? 'upcoming' : (inLibrary ? 'available' : 'released'),
+ inLibrary,
+ canDownload: !inLibrary && !isUpcoming,
+ };
+ });
+
+ // 6. Split into upcoming and recent
+ const upcoming = releases
+ .filter(r => r.status === 'upcoming')
+ .sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime());
+
+ const recent = releases
+ .filter(r => r.status !== 'upcoming')
+ .sort((a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime());
+
+ const response: ReleaseRadarResponse = {
+ upcoming,
+ recent,
+ monitoredArtistCount: monitoredArtists.length,
+ similarArtistCount: unmonitoredSimilar.length,
+ };
+
+ res.json(response);
+ } catch (error: any) {
+ console.error("[Releases] Radar error:", error.message);
+ res.status(500).json({ error: "Failed to fetch release radar" });
+ }
+});
+
+/**
+ * GET /releases/upcoming
+ *
+ * Get only upcoming releases (next X days)
+ */
+router.get("/upcoming", async (req, res) => {
+ try {
+ const daysAhead = parseInt(req.query.days as string) || 90;
+
+ const now = new Date();
+ const endDate = new Date(now);
+ endDate.setDate(endDate.getDate() + daysAhead);
+
+ const releases = await lidarrService.getCalendar(now, endDate);
+
+ // Sort by release date (soonest first)
+ const sorted = releases.sort((a, b) =>
+ new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime()
+ );
+
+ res.json({
+ releases: sorted,
+ count: sorted.length,
+ daysAhead,
+ });
+ } catch (error: any) {
+ console.error("[Releases] Upcoming error:", error.message);
+ res.status(500).json({ error: "Failed to fetch upcoming releases" });
+ }
+});
+
+/**
+ * GET /releases/recent
+ *
+ * Get recently released albums (last X days) that user might want to download
+ */
+router.get("/recent", async (req, res) => {
+ try {
+ const daysBack = parseInt(req.query.days as string) || 30;
+
+ const now = new Date();
+ const startDate = new Date(now);
+ startDate.setDate(startDate.getDate() - daysBack);
+
+ const releases = await lidarrService.getCalendar(startDate, now);
+
+ // Get library albums to mark what's already downloaded
+ const libraryAlbums = await prisma.album.findMany({
+ where: { rgMbid: { not: null } },
+ select: { rgMbid: true }
+ });
+ const libraryMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
+
+ // Filter to releases not in library and sort (newest first)
+ const notInLibrary = releases
+ .filter(r => !r.hasFile && !libraryMbids.has(r.albumMbid))
+ .sort((a, b) =>
+ new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
+ );
+
+ res.json({
+ releases: notInLibrary,
+ count: notInLibrary.length,
+ daysBack,
+ inLibraryCount: releases.length - notInLibrary.length,
+ });
+ } catch (error: any) {
+ console.error("[Releases] Recent error:", error.message);
+ res.status(500).json({ error: "Failed to fetch recent releases" });
+ }
+});
+
+/**
+ * POST /releases/download/:albumMbid
+ *
+ * Download a release from the radar
+ */
+router.post("/download/:albumMbid", async (req, res) => {
+ try {
+ const { albumMbid } = req.params;
+ const userId = req.user?.id;
+
+ if (!userId) {
+ return res.status(401).json({ error: "Authentication required" });
+ }
+
+ console.log(`[Releases] Download requested for album: ${albumMbid}`);
+
+ // Use Lidarr to download the album
+ const result = await lidarrService.downloadAlbum(albumMbid);
+
+ if (result) {
+ res.json({
+ success: true,
+ message: "Download started",
+ albumId: result.id
+ });
+ } else {
+ res.status(404).json({
+ error: "Album not found in Lidarr or download failed"
+ });
+ }
+ } catch (error: any) {
+ console.error("[Releases] Download error:", error.message);
+ res.status(500).json({ error: "Failed to start download" });
+ }
+});
+
+export default router;
+
diff --git a/backend/src/routes/search.ts b/backend/src/routes/search.ts
new file mode 100644
index 0000000..2d901da
--- /dev/null
+++ b/backend/src/routes/search.ts
@@ -0,0 +1,432 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { audiobookshelfService } from "../services/audiobookshelf";
+import { lastFmService } from "../services/lastfm";
+import { searchService } from "../services/search";
+import axios from "axios";
+import { redisClient } from "../utils/redis";
+
+const router = Router();
+
+router.use(requireAuth);
+
+/**
+ * @openapi
+ * /search:
+ * get:
+ * summary: Search across your music library
+ * description: Search for artists, albums, tracks, audiobooks, and podcasts in your library using PostgreSQL full-text search
+ * tags: [Search]
+ * security:
+ * - sessionAuth: []
+ * - apiKeyAuth: []
+ * parameters:
+ * - in: query
+ * name: q
+ * schema:
+ * type: string
+ * required: true
+ * description: Search query
+ * example: "radiohead"
+ * - in: query
+ * name: type
+ * schema:
+ * type: string
+ * enum: [all, artists, albums, tracks, audiobooks, podcasts]
+ * description: Type of content to search
+ * default: all
+ * - in: query
+ * name: genre
+ * schema:
+ * type: string
+ * description: Filter tracks by genre
+ * - in: query
+ * name: limit
+ * schema:
+ * type: integer
+ * minimum: 1
+ * maximum: 100
+ * description: Maximum number of results per type
+ * default: 20
+ * responses:
+ * 200:
+ * description: Search results
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * artists:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Artist'
+ * albums:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Album'
+ * tracks:
+ * type: array
+ * items:
+ * $ref: '#/components/schemas/Track'
+ * audiobooks:
+ * type: array
+ * items:
+ * type: object
+ * podcasts:
+ * type: array
+ * items:
+ * type: object
+ * 401:
+ * description: Not authenticated
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+router.get("/", async (req, res) => {
+ try {
+ const { q = "", type = "all", genre, limit = "20" } = req.query;
+
+ const query = (q as string).trim();
+ const searchLimit = Math.min(parseInt(limit as string, 10), 100);
+
+ if (!query) {
+ return res.json({
+ artists: [],
+ albums: [],
+ tracks: [],
+ audiobooks: [],
+ podcasts: [],
+ });
+ }
+
+ // Check cache for library search (short TTL since library can change)
+ const cacheKey = `search:library:${type}:${genre || ""}:${query}:${searchLimit}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(`[SEARCH] Cache hit for query="${query}"`);
+ return res.json(JSON.parse(cached));
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ const results: any = {
+ artists: [],
+ albums: [],
+ tracks: [],
+ audiobooks: [],
+ podcasts: [],
+ };
+
+ // Search artists using full-text search (only show artists with actual albums in library)
+ if (type === "all" || type === "artists") {
+ const artistResults = await searchService.searchArtists({
+ query,
+ limit: searchLimit,
+ });
+
+ // Filter to only include artists with albums
+ const artistIds = artistResults.map((a) => a.id);
+ const artistsWithAlbums = await prisma.artist.findMany({
+ where: {
+ id: { in: artistIds },
+ albums: {
+ some: {},
+ },
+ },
+ select: {
+ id: true,
+ mbid: true,
+ name: true,
+ heroUrl: true,
+ summary: true,
+ },
+ });
+
+ // Preserve rank order from full-text search
+ const rankMap = new Map(artistResults.map((a) => [a.id, a.rank]));
+ results.artists = artistsWithAlbums.sort((a, b) => {
+ const rankA = rankMap.get(a.id) || 0;
+ const rankB = rankMap.get(b.id) || 0;
+ return rankB - rankA; // Sort by rank DESC
+ });
+ }
+
+ // Search albums using full-text search
+ if (type === "all" || type === "albums") {
+ const albumResults = await searchService.searchAlbums({
+ query,
+ limit: searchLimit,
+ });
+
+ results.albums = albumResults.map((album) => ({
+ id: album.id,
+ title: album.title,
+ artistId: album.artistId,
+ year: album.year,
+ coverUrl: album.coverUrl,
+ artist: {
+ id: album.artistId,
+ name: album.artistName,
+ mbid: "", // Not included in search result
+ },
+ }));
+ }
+
+ // Search tracks using full-text search
+ if (type === "all" || type === "tracks") {
+ const trackResults = await searchService.searchTracks({
+ query,
+ limit: searchLimit,
+ });
+
+ // If genre filter is applied, filter the results
+ if (genre) {
+ const trackIds = trackResults.map((t) => t.id);
+ const tracksWithGenre = await prisma.track.findMany({
+ where: {
+ id: { in: trackIds },
+ trackGenres: {
+ some: {
+ genre: {
+ name: {
+ equals: genre as string,
+ mode: "insensitive",
+ },
+ },
+ },
+ },
+ },
+ select: { id: true },
+ });
+
+ const genreTrackIds = new Set(tracksWithGenre.map((t) => t.id));
+ results.tracks = trackResults
+ .filter((t) => genreTrackIds.has(t.id))
+ .map((track) => ({
+ id: track.id,
+ title: track.title,
+ albumId: track.albumId,
+ duration: track.duration,
+ trackNo: 0, // Not included in search result
+ album: {
+ id: track.albumId,
+ title: track.albumTitle,
+ artistId: track.artistId,
+ coverUrl: null, // Not included in search result
+ artist: {
+ id: track.artistId,
+ name: track.artistName,
+ mbid: "", // Not included in search result
+ },
+ },
+ }));
+ } else {
+ results.tracks = trackResults.map((track) => ({
+ id: track.id,
+ title: track.title,
+ albumId: track.albumId,
+ duration: track.duration,
+ trackNo: 0, // Not included in search result
+ album: {
+ id: track.albumId,
+ title: track.albumTitle,
+ artistId: track.artistId,
+ coverUrl: null, // Not included in search result
+ artist: {
+ id: track.artistId,
+ name: track.artistName,
+ mbid: "", // Not included in search result
+ },
+ },
+ }));
+ }
+ }
+
+ // Search audiobooks
+ if (type === "all" || type === "audiobooks") {
+ try {
+ const audiobooks = await audiobookshelfService.searchAudiobooks(
+ query
+ );
+ results.audiobooks = audiobooks.slice(0, searchLimit);
+ } catch (error) {
+ console.error("Audiobook search error:", error);
+ results.audiobooks = [];
+ }
+ }
+
+ // Search podcasts (search through owned podcasts)
+ if (type === "all" || type === "podcasts") {
+ try {
+ const allPodcasts =
+ await audiobookshelfService.getAllPodcasts();
+ results.podcasts = allPodcasts
+ .filter(
+ (p) =>
+ p.media?.metadata?.title
+ ?.toLowerCase()
+ .includes(query.toLowerCase()) ||
+ p.media?.metadata?.author
+ ?.toLowerCase()
+ .includes(query.toLowerCase())
+ )
+ .slice(0, searchLimit);
+ } catch (error) {
+ console.error("Podcast search error:", error);
+ results.podcasts = [];
+ }
+ }
+
+ // Cache search results for 2 minutes (library can change)
+ try {
+ await redisClient.setEx(cacheKey, 120, JSON.stringify(results));
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ res.json(results);
+ } catch (error) {
+ console.error("Search error:", error);
+ res.status(500).json({ error: "Search failed" });
+ }
+});
+
+// GET /search/genres
+router.get("/genres", async (req, res) => {
+ try {
+ const genres = await prisma.genre.findMany({
+ orderBy: { name: "asc" },
+ include: {
+ _count: {
+ select: { trackGenres: true },
+ },
+ },
+ });
+
+ res.json(
+ genres.map((g) => ({
+ id: g.id,
+ name: g.name,
+ trackCount: g._count.trackGenres,
+ }))
+ );
+ } catch (error) {
+ console.error("Get genres error:", error);
+ res.status(500).json({ error: "Failed to get genres" });
+ }
+});
+
+/**
+ * GET /search/discover?q=query&type=music|podcasts
+ * Search for NEW content to discover (not in your library)
+ */
+router.get("/discover", async (req, res) => {
+ try {
+ const { q = "", type = "music", limit = "20" } = req.query;
+
+ const query = (q as string).trim();
+ const searchLimit = Math.min(parseInt(limit as string, 10), 50);
+
+ if (!query) {
+ return res.json({ results: [] });
+ }
+
+ const cacheKey = `search:discover:${type}:${query}:${searchLimit}`;
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached) {
+ console.log(
+ `[SEARCH DISCOVER] Cache hit for query="${query}" type=${type}`
+ );
+ return res.json(JSON.parse(cached));
+ }
+ } catch (err) {
+ console.warn("[SEARCH DISCOVER] Redis read error:", err);
+ }
+
+ const results: any[] = [];
+
+ if (type === "music" || type === "all") {
+ // Search Last.fm for artists AND tracks
+ try {
+ // Search for artists
+ const lastfmArtistResults = await lastFmService.searchArtists(
+ query,
+ searchLimit
+ );
+ console.log(
+ `[SEARCH ENDPOINT] Found ${lastfmArtistResults.length} artist results`
+ );
+ results.push(...lastfmArtistResults);
+
+ // Search for tracks (songs)
+ const lastfmTrackResults = await lastFmService.searchTracks(
+ query,
+ searchLimit
+ );
+ console.log(
+ `[SEARCH ENDPOINT] Found ${lastfmTrackResults.length} track results`
+ );
+ results.push(...lastfmTrackResults);
+ } catch (error) {
+ console.error("Last.fm search error:", error);
+ }
+ }
+
+ if (type === "podcasts" || type === "all") {
+ // Search iTunes Podcast API
+ try {
+ const itunesResponse = await axios.get(
+ "https://itunes.apple.com/search",
+ {
+ params: {
+ term: query,
+ media: "podcast",
+ entity: "podcast",
+ limit: searchLimit,
+ },
+ timeout: 5000,
+ }
+ );
+
+ const podcasts = itunesResponse.data.results.map(
+ (podcast: any) => ({
+ type: "podcast",
+ id: podcast.collectionId,
+ name: podcast.collectionName,
+ artist: podcast.artistName,
+ description: podcast.description,
+ coverUrl:
+ podcast.artworkUrl600 || podcast.artworkUrl100,
+ feedUrl: podcast.feedUrl,
+ genres: podcast.genres || [],
+ trackCount: podcast.trackCount,
+ })
+ );
+
+ results.push(...podcasts);
+ } catch (error) {
+ console.error("iTunes podcast search error:", error);
+ }
+ }
+
+ const payload = { results };
+
+ try {
+ await redisClient.setEx(cacheKey, 900, JSON.stringify(payload));
+ } catch (err) {
+ console.warn("[SEARCH DISCOVER] Redis write error:", err);
+ }
+
+ res.json(payload);
+ } catch (error) {
+ console.error("Discovery search error:", error);
+ res.status(500).json({ error: "Discovery search failed" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts
new file mode 100644
index 0000000..e980994
--- /dev/null
+++ b/backend/src/routes/settings.ts
@@ -0,0 +1,73 @@
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+
+const router = Router();
+
+router.use(requireAuth);
+
+const settingsSchema = z.object({
+ playbackQuality: z.enum(["original", "high", "medium", "low"]).optional(),
+ wifiOnly: z.boolean().optional(),
+ offlineEnabled: z.boolean().optional(),
+ maxCacheSizeMb: z.number().int().min(0).optional(),
+});
+
+// GET /settings
+router.get("/", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+
+ let settings = await prisma.userSettings.findUnique({
+ where: { userId },
+ });
+
+ // Create default settings if they don't exist
+ if (!settings) {
+ settings = await prisma.userSettings.create({
+ data: {
+ userId,
+ playbackQuality: "medium",
+ wifiOnly: false,
+ offlineEnabled: false,
+ maxCacheSizeMb: 5120,
+ },
+ });
+ }
+
+ res.json(settings);
+ } catch (error) {
+ console.error("Get settings error:", error);
+ res.status(500).json({ error: "Failed to get settings" });
+ }
+});
+
+// POST /settings
+router.post("/", async (req, res) => {
+ try {
+ const userId = req.user!.id;
+ const data = settingsSchema.parse(req.body);
+
+ const settings = await prisma.userSettings.upsert({
+ where: { userId },
+ create: {
+ userId,
+ ...data,
+ },
+ update: data,
+ });
+
+ res.json(settings);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid settings", details: error.errors });
+ }
+ console.error("Update settings error:", error);
+ res.status(500).json({ error: "Failed to update settings" });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/soulseek.ts b/backend/src/routes/soulseek.ts
new file mode 100644
index 0000000..4be669e
--- /dev/null
+++ b/backend/src/routes/soulseek.ts
@@ -0,0 +1,193 @@
+/**
+ * Soulseek routes - Direct connection via slsk-client
+ * Simplified API for status and manual search/download
+ */
+
+import { Router } from "express";
+import { requireAuth } from "../middleware/auth";
+import { soulseekService } from "../services/soulseek";
+import { getSystemSettings } from "../utils/systemSettings";
+
+const router = Router();
+
+// Middleware to check if Soulseek credentials are configured
+async function requireSoulseekConfigured(req: any, res: any, next: any) {
+ try {
+ const available = await soulseekService.isAvailable();
+
+ if (!available) {
+ return res.status(403).json({
+ error: "Soulseek credentials not configured. Add username/password in System Settings.",
+ });
+ }
+
+ next();
+ } catch (error) {
+ console.error("Error checking Soulseek settings:", error);
+ res.status(500).json({ error: "Failed to check settings" });
+ }
+}
+
+/**
+ * GET /soulseek/status
+ * Check connection status
+ */
+router.get("/status", requireAuth, async (req, res) => {
+ try {
+ const available = await soulseekService.isAvailable();
+
+ if (!available) {
+ return res.json({
+ enabled: false,
+ connected: false,
+ message: "Soulseek credentials not configured",
+ });
+ }
+
+ const status = await soulseekService.getStatus();
+
+ res.json({
+ enabled: true,
+ connected: status.connected,
+ username: status.username,
+ });
+ } catch (error: any) {
+ console.error("Soulseek status error:", error.message);
+ res.status(500).json({
+ error: "Failed to get Soulseek status",
+ details: error.message,
+ });
+ }
+});
+
+/**
+ * POST /soulseek/connect
+ * Manually trigger connection to Soulseek network
+ */
+router.post("/connect", requireAuth, requireSoulseekConfigured, async (req, res) => {
+ try {
+ await soulseekService.connect();
+
+ res.json({
+ success: true,
+ message: "Connected to Soulseek network",
+ });
+ } catch (error: any) {
+ console.error("Soulseek connect error:", error.message);
+ res.status(500).json({
+ error: "Failed to connect to Soulseek",
+ details: error.message,
+ });
+ }
+});
+
+/**
+ * POST /soulseek/search
+ * Search for a track
+ */
+router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res) => {
+ try {
+ const { artist, title } = req.body;
+
+ if (!artist || !title) {
+ return res.status(400).json({
+ error: "Artist and title are required",
+ });
+ }
+
+ console.log(`[Soulseek] Searching: "${artist} - ${title}"`);
+
+ const result = await soulseekService.searchTrack(artist, title);
+
+ if (result.found && result.bestMatch) {
+ res.json({
+ found: true,
+ match: {
+ user: result.bestMatch.username,
+ filename: result.bestMatch.filename,
+ size: result.bestMatch.size,
+ quality: result.bestMatch.quality,
+ score: result.bestMatch.score,
+ },
+ });
+ } else {
+ res.json({
+ found: false,
+ message: "No suitable matches found",
+ });
+ }
+ } catch (error: any) {
+ console.error("Soulseek search error:", error.message);
+ res.status(500).json({
+ error: "Search failed",
+ details: error.message,
+ });
+ }
+});
+
+/**
+ * POST /soulseek/download
+ * Download a track directly
+ */
+router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res) => {
+ try {
+ const { artist, title, album } = req.body;
+
+ if (!artist || !title) {
+ return res.status(400).json({
+ error: "Artist and title are required",
+ });
+ }
+
+ const settings = await getSystemSettings();
+ const musicPath = settings?.musicPath;
+
+ if (!musicPath) {
+ return res.status(400).json({
+ error: "Music path not configured",
+ });
+ }
+
+ console.log(`[Soulseek] Downloading: "${artist} - ${title}"`);
+
+ const result = await soulseekService.searchAndDownload(
+ artist,
+ title,
+ album || "Unknown Album",
+ musicPath
+ );
+
+ if (result.success) {
+ res.json({
+ success: true,
+ filePath: result.filePath,
+ });
+ } else {
+ res.status(404).json({
+ success: false,
+ error: result.error || "Download failed",
+ });
+ }
+ } catch (error: any) {
+ console.error("Soulseek download error:", error.message);
+ res.status(500).json({
+ error: "Download failed",
+ details: error.message,
+ });
+ }
+});
+
+/**
+ * POST /soulseek/disconnect
+ * Disconnect from Soulseek network
+ */
+router.post("/disconnect", requireAuth, async (req, res) => {
+ try {
+ soulseekService.disconnect();
+ res.json({ success: true, message: "Disconnected" });
+ } catch (error: any) {
+ res.status(500).json({ error: error.message });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/spotify.ts b/backend/src/routes/spotify.ts
new file mode 100644
index 0000000..c0910d4
--- /dev/null
+++ b/backend/src/routes/spotify.ts
@@ -0,0 +1,334 @@
+import { Router } from "express";
+import { requireAuthOrToken } from "../middleware/auth";
+import { z } from "zod";
+import { spotifyService } from "../services/spotify";
+import { spotifyImportService } from "../services/spotifyImport";
+import { deezerService } from "../services/deezer";
+import { readSessionLog, getSessionLogPath } from "../utils/playlistLogger";
+
+const router = Router();
+
+// All routes require authentication
+router.use(requireAuthOrToken);
+
+// Validation schemas
+const parseUrlSchema = z.object({
+ url: z.string().url(),
+});
+
+const importSchema = z.object({
+ spotifyPlaylistId: z.string(),
+ url: z.string().url().optional(),
+ playlistName: z.string().min(1).max(200),
+ albumMbidsToDownload: z.array(z.string()),
+});
+
+/**
+ * POST /api/spotify/parse
+ * Parse a Spotify URL and return basic info
+ */
+router.post("/parse", async (req, res) => {
+ try {
+ const { url } = parseUrlSchema.parse(req.body);
+
+ const parsed = spotifyService.parseUrl(url);
+ if (!parsed) {
+ return res.status(400).json({
+ error: "Invalid Spotify URL. Please provide a valid playlist URL.",
+ });
+ }
+
+ // For now, only support playlists
+ if (parsed.type !== "playlist") {
+ return res.status(400).json({
+ error: `Only playlist imports are supported. Got: ${parsed.type}`,
+ });
+ }
+
+ res.json({
+ type: parsed.type,
+ id: parsed.id,
+ url: `https://open.spotify.com/playlist/${parsed.id}`,
+ });
+ } catch (error: any) {
+ console.error("Spotify parse error:", error);
+ if (error.name === "ZodError") {
+ return res.status(400).json({ error: "Invalid request body" });
+ }
+ res.status(500).json({ error: error.message || "Failed to parse URL" });
+ }
+});
+
+/**
+ * POST /api/spotify/preview
+ * Generate a preview of what will be imported from a Spotify or Deezer playlist
+ */
+router.post("/preview", async (req, res) => {
+ try {
+ const { url } = parseUrlSchema.parse(req.body);
+
+ console.log(`[Playlist Import] Generating preview for: ${url}`);
+
+ // Detect if it's a Deezer URL
+ if (url.includes("deezer.com")) {
+ // Extract playlist ID from Deezer URL
+ const deezerMatch = url.match(/playlist[\/:](\d+)/);
+ if (!deezerMatch) {
+ return res
+ .status(400)
+ .json({ error: "Invalid Deezer playlist URL" });
+ }
+
+ const playlistId = deezerMatch[1];
+ const deezerPlaylist = await deezerService.getPlaylist(playlistId);
+
+ if (!deezerPlaylist) {
+ return res
+ .status(404)
+ .json({ error: "Deezer playlist not found" });
+ }
+
+ // Convert Deezer format to Spotify Import format
+ const preview =
+ await spotifyImportService.generatePreviewFromDeezer(
+ deezerPlaylist
+ );
+
+ console.log(
+ `[Playlist Import] Deezer preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
+ );
+ res.json(preview);
+ } else {
+ // Handle Spotify URL
+ const preview = await spotifyImportService.generatePreview(url);
+
+ console.log(
+ `[Spotify Import] Preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
+ );
+ res.json(preview);
+ }
+ } catch (error: any) {
+ console.error("Playlist preview error:", error);
+ if (error.name === "ZodError") {
+ return res.status(400).json({ error: "Invalid request body" });
+ }
+ res.status(500).json({
+ error: error.message || "Failed to generate preview",
+ });
+ }
+});
+
+/**
+ * POST /api/spotify/import
+ * Start importing a Spotify playlist
+ */
+router.post("/import", async (req, res) => {
+ try {
+ const { spotifyPlaylistId, url, playlistName, albumMbidsToDownload } =
+ importSchema.parse(req.body);
+ const userId = req.user.id;
+
+ // Re-generate preview to ensure fresh data
+ const effectiveUrl =
+ url?.trim() ||
+ `https://open.spotify.com/playlist/${spotifyPlaylistId}`;
+
+ let preview;
+ if (effectiveUrl.includes("deezer.com")) {
+ const deezerMatch = effectiveUrl.match(/playlist[\/:](\d+)/);
+ if (!deezerMatch) {
+ return res
+ .status(400)
+ .json({ error: "Invalid Deezer playlist URL" });
+ }
+ const playlistId = deezerMatch[1];
+ const deezerPlaylist = await deezerService.getPlaylist(playlistId);
+ if (!deezerPlaylist) {
+ return res
+ .status(404)
+ .json({ error: "Deezer playlist not found" });
+ }
+ preview = await spotifyImportService.generatePreviewFromDeezer(
+ deezerPlaylist
+ );
+ } else {
+ preview = await spotifyImportService.generatePreview(effectiveUrl);
+ }
+
+ console.log(
+ `[Spotify Import] Starting import for user ${userId}: ${playlistName}`
+ );
+ console.log(
+ `[Spotify Import] Downloading ${albumMbidsToDownload.length} albums`
+ );
+
+ const job = await spotifyImportService.startImport(
+ userId,
+ spotifyPlaylistId,
+ playlistName,
+ albumMbidsToDownload,
+ preview
+ );
+
+ res.json({
+ jobId: job.id,
+ status: job.status,
+ message: "Import started",
+ });
+ } catch (error: any) {
+ console.error("Spotify import error:", error);
+ if (error.name === "ZodError") {
+ return res.status(400).json({ error: "Invalid request body" });
+ }
+ res.status(500).json({
+ error: error.message || "Failed to start import",
+ });
+ }
+});
+
+/**
+ * GET /api/spotify/import/:jobId/status
+ * Get the status of an import job
+ */
+router.get("/import/:jobId/status", async (req, res) => {
+ try {
+ const { jobId } = req.params;
+ const userId = req.user.id;
+
+ const job = await spotifyImportService.getJob(jobId);
+ if (!job) {
+ return res.status(404).json({ error: "Import job not found" });
+ }
+
+ // Ensure user owns this job
+ if (job.userId !== userId) {
+ return res
+ .status(403)
+ .json({ error: "Not authorized to view this job" });
+ }
+
+ res.json(job);
+ } catch (error: any) {
+ console.error("Spotify job status error:", error);
+ res.status(500).json({
+ error: error.message || "Failed to get job status",
+ });
+ }
+});
+
+/**
+ * GET /api/spotify/imports
+ * Get all import jobs for the current user
+ */
+router.get("/imports", async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const jobs = await spotifyImportService.getUserJobs(userId);
+ res.json(jobs);
+ } catch (error: any) {
+ console.error("Spotify imports error:", error);
+ res.status(500).json({
+ error: error.message || "Failed to get imports",
+ });
+ }
+});
+
+/**
+ * POST /api/spotify/import/:jobId/refresh
+ * Re-match pending tracks and add newly downloaded ones to the playlist
+ */
+router.post("/import/:jobId/refresh", async (req, res) => {
+ try {
+ const { jobId } = req.params;
+ const userId = req.user.id;
+
+ const job = await spotifyImportService.getJob(jobId);
+ if (!job) {
+ return res.status(404).json({ error: "Import job not found" });
+ }
+
+ // Ensure user owns this job
+ if (job.userId !== userId) {
+ return res
+ .status(403)
+ .json({ error: "Not authorized to refresh this job" });
+ }
+
+ const result = await spotifyImportService.refreshJobMatches(jobId);
+
+ res.json({
+ message:
+ result.added > 0
+ ? `Added ${result.added} newly downloaded track(s)`
+ : "No new tracks found yet. Albums may still be downloading.",
+ added: result.added,
+ total: result.total,
+ });
+ } catch (error: any) {
+ console.error("Spotify refresh error:", error);
+ res.status(500).json({
+ error: error.message || "Failed to refresh tracks",
+ });
+ }
+});
+
+/**
+ * POST /api/spotify/import/:jobId/cancel
+ * Cancel an import job and create playlist with whatever succeeded
+ */
+router.post("/import/:jobId/cancel", async (req, res) => {
+ try {
+ const { jobId } = req.params;
+ const userId = req.user.id;
+
+ const job = await spotifyImportService.getJob(jobId);
+ if (!job) {
+ return res.status(404).json({ error: "Import job not found" });
+ }
+
+ // Ensure user owns this job
+ if (job.userId !== userId) {
+ return res
+ .status(403)
+ .json({ error: "Not authorized to cancel this job" });
+ }
+
+ const result = await spotifyImportService.cancelJob(jobId);
+
+ res.json({
+ message: result.playlistCreated
+ ? `Import cancelled. Playlist created with ${result.tracksMatched} track(s).`
+ : "Import cancelled. No tracks were downloaded.",
+ playlistId: result.playlistId,
+ tracksMatched: result.tracksMatched,
+ });
+ } catch (error: any) {
+ console.error("Spotify cancel error:", error);
+ res.status(500).json({
+ error: error.message || "Failed to cancel import",
+ });
+ }
+});
+
+/**
+ * GET /api/spotify/import/session-log
+ * Get the current session log for debugging import issues
+ */
+router.get("/import/session-log", async (req, res) => {
+ try {
+ const log = readSessionLog();
+ const logPath = getSessionLogPath();
+
+ res.json({
+ path: logPath,
+ content: log,
+ });
+ } catch (error: any) {
+ console.error("Session log error:", error);
+ res.status(500).json({
+ error: error.message || "Failed to read session log",
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/systemSettings.ts b/backend/src/routes/systemSettings.ts
new file mode 100644
index 0000000..9f6b8b3
--- /dev/null
+++ b/backend/src/routes/systemSettings.ts
@@ -0,0 +1,712 @@
+import { Router } from "express";
+import { requireAuth, requireAdmin } from "../middleware/auth";
+import { prisma } from "../utils/db";
+import { z } from "zod";
+import { writeEnvFile } from "../utils/envWriter";
+import { invalidateSystemSettingsCache } from "../utils/systemSettings";
+import { queueCleaner } from "../jobs/queueCleaner";
+import { encrypt, decrypt } from "../utils/encryption";
+
+const router = Router();
+
+/**
+ * Safely decrypt a field, returning null if decryption fails
+ */
+function safeDecrypt(value: string | null): string | null {
+ if (!value) return null;
+ try {
+ return decrypt(value);
+ } catch (error) {
+ console.warn("[Settings Route] Failed to decrypt field, returning null");
+ return null;
+ }
+}
+
+// Only admins can access system settings
+router.use(requireAuth);
+router.use(requireAdmin);
+
+const systemSettingsSchema = z.object({
+ // Download Services
+ lidarrEnabled: z.boolean().optional(),
+ lidarrUrl: z.string().optional(),
+ lidarrApiKey: z.string().nullable().optional(),
+
+ // AI Services
+ openaiEnabled: z.boolean().optional(),
+ openaiApiKey: z.string().nullable().optional(),
+ openaiModel: z.string().optional(),
+ openaiBaseUrl: z.string().nullable().optional(),
+
+ fanartEnabled: z.boolean().optional(),
+ fanartApiKey: z.string().nullable().optional(),
+
+ // Media Services
+ audiobookshelfEnabled: z.boolean().optional(),
+ audiobookshelfUrl: z.string().optional(),
+ audiobookshelfApiKey: z.string().nullable().optional(),
+
+ // Soulseek (direct connection via slsk-client)
+ soulseekUsername: z.string().nullable().optional(),
+ soulseekPassword: z.string().nullable().optional(),
+
+ // Spotify (for playlist import)
+ spotifyClientId: z.string().nullable().optional(),
+ spotifyClientSecret: z.string().nullable().optional(),
+
+ // Storage Paths
+ musicPath: z.string().optional(),
+ downloadPath: z.string().optional(),
+
+ // Feature Flags
+ autoSync: z.boolean().optional(),
+ autoEnrichMetadata: z.boolean().optional(),
+
+ // Advanced Settings
+ maxConcurrentDownloads: z.number().optional(),
+ downloadRetryAttempts: z.number().optional(),
+ transcodeCacheMaxGb: z.number().optional(),
+
+ // Download Preferences
+ downloadSource: z.enum(["soulseek", "lidarr"]).optional(),
+ soulseekFallback: z.enum(["none", "lidarr"]).optional(),
+});
+
+// GET /system-settings
+router.get("/", async (req, res) => {
+ try {
+ let settings = await prisma.systemSettings.findUnique({
+ where: { id: "default" },
+ });
+
+ // Create default settings if they don't exist
+ if (!settings) {
+ settings = await prisma.systemSettings.create({
+ data: {
+ id: "default",
+ lidarrEnabled: true,
+ lidarrUrl: "http://localhost:8686",
+ openaiEnabled: false,
+ openaiModel: "gpt-4",
+ fanartEnabled: false,
+ audiobookshelfEnabled: false,
+ audiobookshelfUrl: "http://localhost:13378",
+ musicPath: "/music",
+ downloadPath: "/downloads",
+ autoSync: true,
+ autoEnrichMetadata: true,
+ maxConcurrentDownloads: 3,
+ downloadRetryAttempts: 3,
+ transcodeCacheMaxGb: 10,
+ },
+ });
+ }
+
+ // Decrypt sensitive fields before sending to client
+ // Use safeDecrypt to handle corrupted encrypted values gracefully
+ const decryptedSettings = {
+ ...settings,
+ lidarrApiKey: safeDecrypt(settings.lidarrApiKey),
+ openaiApiKey: safeDecrypt(settings.openaiApiKey),
+ fanartApiKey: safeDecrypt(settings.fanartApiKey),
+ audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey),
+ soulseekPassword: safeDecrypt(settings.soulseekPassword),
+ spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret),
+ };
+
+ res.json(decryptedSettings);
+ } catch (error) {
+ console.error("Get system settings error:", error);
+ res.status(500).json({ error: "Failed to get system settings" });
+ }
+});
+
+// POST /system-settings
+router.post("/", async (req, res) => {
+ try {
+ const data = systemSettingsSchema.parse(req.body);
+
+ console.log("[SYSTEM SETTINGS] Saving settings...");
+ console.log(
+ "[SYSTEM SETTINGS] transcodeCacheMaxGb:",
+ data.transcodeCacheMaxGb
+ );
+
+ // Encrypt sensitive fields
+ const encryptedData: any = { ...data };
+
+ if (data.lidarrApiKey)
+ encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey);
+ if (data.openaiApiKey)
+ encryptedData.openaiApiKey = encrypt(data.openaiApiKey);
+ if (data.fanartApiKey)
+ encryptedData.fanartApiKey = encrypt(data.fanartApiKey);
+ if (data.audiobookshelfApiKey)
+ encryptedData.audiobookshelfApiKey = encrypt(
+ data.audiobookshelfApiKey
+ );
+ if (data.soulseekPassword)
+ encryptedData.soulseekPassword = encrypt(data.soulseekPassword);
+ if (data.spotifyClientSecret)
+ encryptedData.spotifyClientSecret = encrypt(data.spotifyClientSecret);
+
+ const settings = await prisma.systemSettings.upsert({
+ where: { id: "default" },
+ create: {
+ id: "default",
+ ...encryptedData,
+ },
+ update: encryptedData,
+ });
+
+ invalidateSystemSettingsCache();
+
+ // If Audiobookshelf was disabled, clear all audiobook-related data
+ if (data.audiobookshelfEnabled === false) {
+ console.log(
+ "[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database"
+ );
+ try {
+ const deletedProgress =
+ await prisma.audiobookProgress.deleteMany({});
+ console.log(
+ ` Deleted ${deletedProgress.count} audiobook progress entries`
+ );
+ } catch (clearError) {
+ console.error("Failed to clear audiobook data:", clearError);
+ // Don't fail the request
+ }
+ }
+
+ // Write to .env file for Docker containers
+ try {
+ await writeEnvFile({
+ LIDARR_ENABLED: data.lidarrEnabled ? "true" : "false",
+ LIDARR_URL: data.lidarrUrl || null,
+ LIDARR_API_KEY: data.lidarrApiKey || null,
+ FANART_API_KEY: data.fanartApiKey || null,
+ OPENAI_API_KEY: data.openaiApiKey || null,
+ AUDIOBOOKSHELF_URL: data.audiobookshelfUrl || null,
+ AUDIOBOOKSHELF_API_KEY: data.audiobookshelfApiKey || null,
+ SOULSEEK_USERNAME: data.soulseekUsername || null,
+ SOULSEEK_PASSWORD: data.soulseekPassword || null,
+ });
+ console.log(".env file synchronized with database settings");
+ } catch (envError) {
+ console.error("Failed to write .env file:", envError);
+ // Don't fail the request if .env write fails
+ }
+
+ // Auto-configure Lidarr webhook if Lidarr is enabled
+ if (data.lidarrEnabled && data.lidarrUrl && data.lidarrApiKey) {
+ try {
+ console.log("[LIDARR] Auto-configuring webhook...");
+
+ const axios = (await import("axios")).default;
+ const lidarrUrl = data.lidarrUrl;
+ const apiKey = data.lidarrApiKey;
+
+ // Determine webhook URL
+ // Use LIDIFY_CALLBACK_URL env var if set, otherwise default to host.docker.internal:3030
+ // Port 3030 is the external Nginx port that Lidarr can reach
+ const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://host.docker.internal:3030";
+ const webhookUrl = `${callbackHost}/api/webhooks/lidarr`;
+
+ console.log(` Webhook URL: ${webhookUrl}`);
+
+ // Check if webhook already exists - find by name "Lidify" OR by URL containing "lidify" or "webhooks/lidarr"
+ const notificationsResponse = await axios.get(
+ `${lidarrUrl}/api/v1/notification`,
+ {
+ headers: { "X-Api-Key": apiKey },
+ timeout: 10000,
+ }
+ );
+
+ // Find existing Lidify webhook by name (primary) or URL pattern (fallback)
+ const existingWebhook = notificationsResponse.data.find(
+ (n: any) =>
+ n.implementation === "Webhook" &&
+ (
+ // Match by name
+ n.name === "Lidify" ||
+ // Or match by URL pattern (catches old webhooks with different URLs)
+ n.fields?.find(
+ (f: any) =>
+ f.name === "url" &&
+ (f.value?.includes("webhooks/lidarr") || f.value?.includes("lidify"))
+ )
+ )
+ );
+
+ if (existingWebhook) {
+ const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value;
+ console.log(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`);
+ if (currentUrl !== webhookUrl) {
+ console.log(` URL needs updating from: ${currentUrl}`);
+ console.log(` URL will be updated to: ${webhookUrl}`);
+ }
+ }
+
+ const webhookConfig = {
+ onGrab: true,
+ onReleaseImport: true,
+ onAlbumDownload: true,
+ onDownloadFailure: true,
+ onImportFailure: true,
+ onAlbumDelete: true,
+ onRename: true,
+ onHealthIssue: false,
+ onApplicationUpdate: false,
+ supportsOnGrab: true,
+ supportsOnReleaseImport: true,
+ supportsOnAlbumDownload: true,
+ supportsOnDownloadFailure: true,
+ supportsOnImportFailure: true,
+ supportsOnAlbumDelete: true,
+ supportsOnRename: true,
+ supportsOnHealthIssue: true,
+ supportsOnApplicationUpdate: true,
+ includeHealthWarnings: false,
+ name: "Lidify",
+ implementation: "Webhook",
+ implementationName: "Webhook",
+ configContract: "WebhookSettings",
+ infoLink:
+ "https://wiki.servarr.com/lidarr/supported#webhook",
+ tags: [],
+ fields: [
+ { name: "url", value: webhookUrl },
+ { name: "method", value: 1 }, // 1 = POST
+ { name: "username", value: "" },
+ { name: "password", value: "" },
+ ],
+ };
+
+ if (existingWebhook) {
+ // Update existing webhook
+ await axios.put(
+ `${lidarrUrl}/api/v1/notification/${existingWebhook.id}?forceSave=true`,
+ { ...existingWebhook, ...webhookConfig },
+ {
+ headers: { "X-Api-Key": apiKey },
+ timeout: 10000,
+ }
+ );
+ console.log(" Webhook updated");
+ } else {
+ // Create new webhook (use forceSave to skip test)
+ await axios.post(
+ `${lidarrUrl}/api/v1/notification?forceSave=true`,
+ webhookConfig,
+ {
+ headers: { "X-Api-Key": apiKey },
+ timeout: 10000,
+ }
+ );
+ console.log(" Webhook created");
+ }
+
+ console.log("Lidarr webhook configured automatically\n");
+ } catch (webhookError: any) {
+ console.error(
+ "Failed to auto-configure webhook:",
+ webhookError.message
+ );
+ if (webhookError.response?.data) {
+ console.error(
+ " Lidarr error details:",
+ JSON.stringify(webhookError.response.data, null, 2)
+ );
+ }
+ console.log(
+ " User can configure webhook manually in Lidarr UI\n"
+ );
+ // Don't fail the request if webhook config fails
+ }
+ }
+
+ res.json({
+ success: true,
+ message:
+ "Settings saved successfully. Restart Docker containers to apply changes.",
+ requiresRestart: true,
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return res
+ .status(400)
+ .json({ error: "Invalid settings", details: error.errors });
+ }
+ console.error("Update system settings error:", error);
+ res.status(500).json({ error: "Failed to update system settings" });
+ }
+});
+
+// POST /system-settings/test-lidarr
+router.post("/test-lidarr", async (req, res) => {
+ try {
+ const { url, apiKey } = req.body;
+
+ console.log("[Lidarr Test] Testing connection to:", url);
+
+ if (!url || !apiKey) {
+ return res
+ .status(400)
+ .json({ error: "URL and API key are required" });
+ }
+
+ // Normalize URL - remove trailing slash
+ const normalizedUrl = url.replace(/\/+$/, "");
+
+ const axios = require("axios");
+ const response = await axios.get(
+ `${normalizedUrl}/api/v1/system/status`,
+ {
+ headers: { "X-Api-Key": apiKey },
+ timeout: 10000,
+ }
+ );
+
+ console.log(
+ "[Lidarr Test] Connection successful, version:",
+ response.data.version
+ );
+
+ res.json({
+ success: true,
+ message: "Lidarr connection successful",
+ version: response.data.version,
+ });
+ } catch (error: any) {
+ console.error("[Lidarr Test] Error:", error.message);
+ console.error(
+ "[Lidarr Test] Details:",
+ error.response?.data || error.code
+ );
+
+ let details = error.message;
+ if (error.code === "ECONNREFUSED") {
+ details =
+ "Connection refused - check if Lidarr is running and accessible";
+ } else if (error.code === "ENOTFOUND") {
+ details = "Host not found - check the URL";
+ } else if (error.response?.status === 401) {
+ details = "Invalid API key";
+ } else if (error.response?.data?.message) {
+ details = error.response.data.message;
+ }
+
+ res.status(500).json({
+ error: "Failed to connect to Lidarr",
+ details,
+ });
+ }
+});
+
+// POST /system-settings/test-openai
+router.post("/test-openai", async (req, res) => {
+ try {
+ const { apiKey, model } = req.body;
+
+ if (!apiKey) {
+ return res.status(400).json({ error: "API key is required" });
+ }
+
+ const axios = require("axios");
+ const response = await axios.post(
+ "https://api.openai.com/v1/chat/completions",
+ {
+ model: model || "gpt-3.5-turbo",
+ messages: [{ role: "user", content: "Test" }],
+ max_tokens: 5,
+ },
+ {
+ headers: { Authorization: `Bearer ${apiKey}` },
+ timeout: 10000,
+ }
+ );
+
+ res.json({
+ success: true,
+ message: "OpenAI connection successful",
+ model: response.data.model,
+ });
+ } catch (error: any) {
+ console.error("OpenAI test error:", error.message);
+ res.status(500).json({
+ error: "Failed to connect to OpenAI",
+ details: error.response?.data?.error?.message || error.message,
+ });
+ }
+});
+
+// Test Fanart.tv connection
+router.post("/test-fanart", async (req, res) => {
+ try {
+ const { fanartApiKey } = req.body;
+
+ if (!fanartApiKey) {
+ return res.status(400).json({ error: "API key is required" });
+ }
+
+ const axios = require("axios");
+
+ // Test with a known artist (The Beatles MBID)
+ const testMbid = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d";
+
+ const response = await axios.get(
+ `https://webservice.fanart.tv/v3/music/${testMbid}`,
+ {
+ params: { api_key: fanartApiKey },
+ timeout: 5000,
+ }
+ );
+
+ // If we get here, the API key is valid
+ res.json({
+ success: true,
+ message: "Fanart.tv connection successful",
+ });
+ } catch (error: any) {
+ console.error("Fanart.tv test error:", error.message);
+ if (error.response?.status === 401) {
+ res.status(401).json({
+ error: "Invalid Fanart.tv API key",
+ });
+ } else {
+ res.status(500).json({
+ error: "Failed to connect to Fanart.tv",
+ details: error.response?.data || error.message,
+ });
+ }
+ }
+});
+
+// Test Audiobookshelf connection
+router.post("/test-audiobookshelf", async (req, res) => {
+ try {
+ const { url, apiKey } = req.body;
+
+ if (!url || !apiKey) {
+ return res
+ .status(400)
+ .json({ error: "URL and API key are required" });
+ }
+
+ const axios = require("axios");
+
+ const response = await axios.get(`${url}/api/libraries`, {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ },
+ timeout: 5000,
+ });
+
+ res.json({
+ success: true,
+ message: "Audiobookshelf connection successful",
+ libraries: response.data.libraries?.length || 0,
+ });
+ } catch (error: any) {
+ console.error("Audiobookshelf test error:", error.message);
+ if (error.response?.status === 401 || error.response?.status === 403) {
+ res.status(401).json({
+ error: "Invalid Audiobookshelf API key",
+ });
+ } else {
+ res.status(500).json({
+ error: "Failed to connect to Audiobookshelf",
+ details: error.response?.data || error.message,
+ });
+ }
+ }
+});
+
+// Test Soulseek connection (direct via slsk-client)
+router.post("/test-soulseek", async (req, res) => {
+ try {
+ const { username, password } = req.body;
+
+ if (!username || !password) {
+ return res.status(400).json({
+ error: "Soulseek username and password are required",
+ });
+ }
+
+ console.log(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
+
+ // Import soulseek service
+ const { soulseekService } = await import("../services/soulseek");
+
+ // Temporarily set credentials for test
+ // The service will use the provided credentials
+ try {
+ // Try to connect with the provided credentials
+ const slsk = require("slsk-client");
+
+ await new Promise((resolve, reject) => {
+ slsk.connect(
+ { user: username, pass: password },
+ (err: Error | null, client: any) => {
+ if (err) {
+ console.log(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
+ return reject(err);
+ }
+ console.log(`[SOULSEEK-TEST] Connected successfully`);
+ // We don't need to keep the connection open for the test
+ resolve();
+ }
+ );
+ });
+
+ res.json({
+ success: true,
+ message: `Connected to Soulseek as "${username}"`,
+ soulseekUsername: username,
+ isConnected: true,
+ });
+ } catch (connectError: any) {
+ console.error(`[SOULSEEK-TEST] Error: ${connectError.message}`);
+ res.status(401).json({
+ error: "Invalid Soulseek credentials or connection failed",
+ details: connectError.message,
+ });
+ }
+ } catch (error: any) {
+ console.error("[SOULSEEK-TEST] Error:", error.message);
+ res.status(500).json({
+ error: "Failed to test Soulseek connection",
+ details: error.message,
+ });
+ }
+});
+
+// Test Spotify credentials
+router.post("/test-spotify", async (req, res) => {
+ try {
+ const { clientId, clientSecret } = req.body;
+
+ if (!clientId || !clientSecret) {
+ return res.status(400).json({
+ error: "Client ID and Client Secret are required"
+ });
+ }
+
+ // Import spotifyService to test credentials
+ const { spotifyService } = await import("../services/spotify");
+ const result = await spotifyService.testCredentials(clientId, clientSecret);
+
+ if (result.success) {
+ res.json({
+ success: true,
+ message: "Spotify credentials are valid",
+ });
+ } else {
+ res.status(401).json({
+ error: result.error || "Invalid Spotify credentials",
+ });
+ }
+ } catch (error: any) {
+ console.error("Spotify test error:", error.message);
+ res.status(500).json({
+ error: "Failed to test Spotify credentials",
+ details: error.message,
+ });
+ }
+});
+
+// Get queue cleaner status
+router.get("/queue-cleaner-status", (req, res) => {
+ res.json(queueCleaner.getStatus());
+});
+
+// Start queue cleaner manually
+router.post("/queue-cleaner/start", async (req, res) => {
+ try {
+ await queueCleaner.start();
+ res.json({
+ success: true,
+ message: "Queue cleaner started",
+ status: queueCleaner.getStatus(),
+ });
+ } catch (error: any) {
+ res.status(500).json({
+ error: "Failed to start queue cleaner",
+ details: error.message,
+ });
+ }
+});
+
+// Stop queue cleaner manually
+router.post("/queue-cleaner/stop", (req, res) => {
+ queueCleaner.stop();
+ res.json({
+ success: true,
+ message: "Queue cleaner stopped",
+ status: queueCleaner.getStatus(),
+ });
+});
+
+// Clear all Redis caches
+router.post("/clear-caches", async (req, res) => {
+ try {
+ const { redisClient } = require("../utils/redis");
+ const { notificationService } = await import("../services/notificationService");
+
+ // Get all keys but exclude session keys
+ const allKeys = await redisClient.keys("*");
+ const keysToDelete = allKeys.filter(
+ (key: string) => !key.startsWith("sess:")
+ );
+
+ if (keysToDelete.length > 0) {
+ console.log(
+ `[CACHE] Clearing ${
+ keysToDelete.length
+ } cache entries (excluding ${
+ allKeys.length - keysToDelete.length
+ } session keys)...`
+ );
+ for (const key of keysToDelete) {
+ await redisClient.del(key);
+ }
+ console.log(
+ `[CACHE] Successfully cleared ${keysToDelete.length} cache entries`
+ );
+
+ // Send notification to user
+ await notificationService.notifySystem(
+ req.user!.id,
+ "Caches Cleared",
+ `Successfully cleared ${keysToDelete.length} cache entries`
+ );
+
+ res.json({
+ success: true,
+ message: `Cleared ${keysToDelete.length} cache entries`,
+ clearedKeys: keysToDelete.length,
+ });
+ } else {
+ await notificationService.notifySystem(
+ req.user!.id,
+ "Caches Cleared",
+ "No cache entries to clear"
+ );
+
+ res.json({
+ success: true,
+ message: "No cache entries to clear",
+ clearedKeys: 0,
+ });
+ }
+ } catch (error: any) {
+ console.error("Clear caches error:", error);
+ res.status(500).json({
+ error: "Failed to clear caches",
+ details: error.message,
+ });
+ }
+});
+
+export default router;
diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts
new file mode 100644
index 0000000..953829f
--- /dev/null
+++ b/backend/src/routes/webhooks.ts
@@ -0,0 +1,231 @@
+/**
+ * Lidarr Webhook Handler (Refactored)
+ *
+ * Handles Lidarr webhooks for download tracking and Discovery Weekly integration.
+ * Uses the stateless simpleDownloadManager for all operations.
+ */
+
+import { Router } from "express";
+import { prisma } from "../utils/db";
+import { scanQueue } from "../workers/queues";
+import { discoverWeeklyService } from "../services/discoverWeekly";
+import { simpleDownloadManager } from "../services/simpleDownloadManager";
+import { queueCleaner } from "../jobs/queueCleaner";
+import { getSystemSettings } from "../utils/systemSettings";
+
+const router = Router();
+
+// POST /webhooks/lidarr - Handle Lidarr webhooks
+router.post("/lidarr", async (req, res) => {
+ try {
+ // Check if Lidarr is enabled before processing any webhooks
+ const settings = await getSystemSettings();
+ if (
+ !settings?.lidarrEnabled ||
+ !settings?.lidarrUrl ||
+ !settings?.lidarrApiKey
+ ) {
+ console.log(
+ `[WEBHOOK] Lidarr webhook received but Lidarr is disabled. Ignoring.`
+ );
+ return res.status(202).json({
+ success: true,
+ ignored: true,
+ reason: "lidarr-disabled",
+ });
+ }
+
+ const eventType = req.body.eventType;
+ console.log(`[WEBHOOK] Lidarr event: ${eventType}`);
+
+ // Log payload in debug mode only (avoid verbose logs in production)
+ if (process.env.DEBUG_WEBHOOKS === "true") {
+ console.log(` Payload:`, JSON.stringify(req.body, null, 2));
+ }
+
+ switch (eventType) {
+ case "Grab":
+ await handleGrab(req.body);
+ break;
+
+ case "Download":
+ case "AlbumDownload":
+ case "TrackRetag":
+ case "Rename":
+ await handleDownload(req.body);
+ break;
+
+ case "ImportFailure":
+ case "DownloadFailed":
+ case "DownloadFailure":
+ await handleImportFailure(req.body);
+ break;
+
+ case "Health":
+ case "HealthIssue":
+ case "HealthRestored":
+ // Ignore health events
+ break;
+
+ case "Test":
+ console.log(" Lidarr test webhook received");
+ break;
+
+ default:
+ console.log(` Unhandled event: ${eventType}`);
+ }
+
+ res.json({ success: true });
+ } catch (error: any) {
+ console.error("Webhook error:", error.message);
+ res.status(500).json({ error: "Webhook processing failed" });
+ }
+});
+
+/**
+ * Handle Grab event (download started by Lidarr)
+ */
+async function handleGrab(payload: any) {
+ const downloadId = payload.downloadId;
+ const albumMbid =
+ payload.albums?.[0]?.foreignAlbumId || payload.albums?.[0]?.mbId;
+ const albumTitle = payload.albums?.[0]?.title;
+ const artistName = payload.artist?.name;
+ const lidarrAlbumId = payload.albums?.[0]?.id;
+
+ console.log(` Album: ${artistName} - ${albumTitle}`);
+ console.log(` Download ID: ${downloadId}`);
+ console.log(` MBID: ${albumMbid}`);
+
+ if (!downloadId) {
+ console.log(` Missing downloadId, skipping`);
+ return;
+ }
+
+ // Use the download manager's multi-strategy matching
+ const result = await simpleDownloadManager.onDownloadGrabbed(
+ downloadId,
+ albumMbid || "",
+ albumTitle || "",
+ artistName || "",
+ lidarrAlbumId || 0
+ );
+
+ if (result.matched) {
+ // Start queue cleaner to monitor this download
+ queueCleaner.start();
+ }
+}
+
+/**
+ * Handle Download event (download complete + imported)
+ */
+async function handleDownload(payload: any) {
+ const downloadId = payload.downloadId;
+ const albumTitle = payload.album?.title || payload.albums?.[0]?.title;
+ const artistName = payload.artist?.name;
+ const albumMbid =
+ payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
+ const lidarrAlbumId = payload.album?.id || payload.albums?.[0]?.id;
+
+ console.log(` Album: ${artistName} - ${albumTitle}`);
+ console.log(` Download ID: ${downloadId}`);
+ console.log(` Album MBID: ${albumMbid}`);
+ console.log(` Lidarr Album ID: ${lidarrAlbumId}`);
+
+ if (!downloadId) {
+ console.log(` Missing downloadId, skipping`);
+ return;
+ }
+
+ // Handle completion through download manager
+ const result = await simpleDownloadManager.onDownloadComplete(
+ downloadId,
+ albumMbid,
+ artistName,
+ albumTitle,
+ lidarrAlbumId
+ );
+
+ if (result.jobId) {
+ // Check if this is part of a download batch (artist download)
+ if (result.downloadBatchId) {
+ // Check if all jobs in the batch are complete
+ const batchComplete = await checkDownloadBatchComplete(
+ result.downloadBatchId
+ );
+ if (batchComplete) {
+ console.log(
+ ` All albums in batch complete, triggering library scan...`
+ );
+ await scanQueue.add("scan", {
+ type: "full",
+ source: "lidarr-import-batch",
+ });
+ } else {
+ console.log(` Batch not complete, skipping scan`);
+ }
+ } else if (!result.batchId) {
+ // Single album download (not part of discovery batch)
+ console.log(` Triggering library scan...`);
+ await scanQueue.add("scan", {
+ type: "full",
+ source: "lidarr-import",
+ });
+ }
+ // If part of discovery batch, the download manager already called checkBatchCompletion
+ } else {
+ // No job found - this might be an external download not initiated by us
+ // Still trigger a scan to pick up the new music
+ console.log(` No matching job, triggering scan anyway...`);
+ await scanQueue.add("scan", {
+ type: "full",
+ source: "lidarr-import-external",
+ });
+ }
+}
+
+/**
+ * Check if all jobs in a download batch are complete
+ */
+async function checkDownloadBatchComplete(batchId: string): Promise {
+ const pendingJobs = await prisma.downloadJob.count({
+ where: {
+ metadata: {
+ path: ["batchId"],
+ equals: batchId,
+ },
+ status: { in: ["pending", "processing"] },
+ },
+ });
+
+ console.log(
+ ` Batch ${batchId}: ${pendingJobs} pending/processing jobs remaining`
+ );
+ return pendingJobs === 0;
+}
+
+/**
+ * Handle import failure with automatic retry
+ */
+async function handleImportFailure(payload: any) {
+ const downloadId = payload.downloadId;
+ const albumMbid =
+ payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
+ const albumTitle = payload.album?.title || payload.release?.title;
+ const reason = payload.message || "Import failed";
+
+ console.log(` Album: ${albumTitle}`);
+ console.log(` Download ID: ${downloadId}`);
+ console.log(` Reason: ${reason}`);
+
+ if (!downloadId) {
+ console.log(` Missing downloadId, skipping`);
+ return;
+ }
+
+ // Handle failure through download manager (handles retry logic)
+ await simpleDownloadManager.onImportFailed(downloadId, reason, albumMbid);
+}
+
+export default router;
diff --git a/backend/src/services/audioStreaming.ts b/backend/src/services/audioStreaming.ts
new file mode 100644
index 0000000..578c650
--- /dev/null
+++ b/backend/src/services/audioStreaming.ts
@@ -0,0 +1,395 @@
+import * as fs from "fs";
+import * as path from "path";
+import * as crypto from "crypto";
+import { prisma } from "../utils/db";
+import ffmpeg from "fluent-ffmpeg";
+import ffmpegPath from "@ffmpeg-installer/ffmpeg";
+import PQueue from "p-queue";
+import { AppError, ErrorCode, ErrorCategory } from "../utils/errors";
+import { parseFile } from "music-metadata";
+
+// Set FFmpeg path to bundled binary
+ffmpeg.setFfmpegPath(ffmpegPath.path);
+
+// Quality settings
+export const QUALITY_SETTINGS = {
+ original: { bitrate: null, format: null }, // No transcoding
+ high: { bitrate: 320, format: "mp3" },
+ medium: { bitrate: 192, format: "mp3" },
+ low: { bitrate: 128, format: "mp3" },
+} as const;
+
+export type Quality = keyof typeof QUALITY_SETTINGS;
+
+interface StreamFileInfo {
+ filePath: string;
+ mimeType: string;
+}
+
+export class AudioStreamingService {
+ private transcodeQueue = new PQueue({ concurrency: 3 });
+ private musicPath: string;
+ private transcodeCachePath: string;
+ private transcodeCacheMaxGb: number;
+ private evictionInterval: NodeJS.Timeout | null = null;
+
+ constructor(
+ musicPath: string,
+ transcodeCachePath: string,
+ transcodeCacheMaxGb: number
+ ) {
+ this.musicPath = musicPath;
+ this.transcodeCachePath = transcodeCachePath;
+ this.transcodeCacheMaxGb = transcodeCacheMaxGb;
+
+ // Ensure cache directory exists
+ if (!fs.existsSync(this.transcodeCachePath)) {
+ fs.mkdirSync(this.transcodeCachePath, { recursive: true });
+ }
+
+ // Start cache eviction timer (every 6 hours)
+ this.evictionInterval = setInterval(() => {
+ this.evictCache(this.transcodeCacheMaxGb).catch((err) => {
+ console.error("Cache eviction failed:", err);
+ });
+ }, 6 * 60 * 60 * 1000);
+ }
+
+ /**
+ * Get file path for streaming (either original or transcoded)
+ */
+ async getStreamFilePath(
+ trackId: string,
+ quality: Quality,
+ sourceModified: Date,
+ sourceAbsolutePath: string
+ ): Promise {
+ console.log(`[AudioStreaming] Request: trackId=${trackId}, quality=${quality}, source=${path.basename(sourceAbsolutePath)}`);
+
+ // If original quality requested, return source file
+ if (quality === "original") {
+ const mimeType = this.getMimeType(sourceAbsolutePath);
+ console.log(`[AudioStreaming] Serving original: mimeType=${mimeType}`);
+ return {
+ filePath: sourceAbsolutePath,
+ mimeType,
+ };
+ }
+
+ // Check if we have a valid cached transcode
+ const cachedPath = await this.getCachedTranscode(
+ trackId,
+ quality,
+ sourceModified
+ );
+
+ if (cachedPath) {
+ console.log(
+ `[STREAM] Using cached transcode: ${quality} (${cachedPath})`
+ );
+ return {
+ filePath: cachedPath,
+ mimeType: "audio/mpeg",
+ };
+ }
+
+ // Check source file bitrate to avoid pointless upsampling
+ const targetBitrate = QUALITY_SETTINGS[quality].bitrate;
+ if (targetBitrate) {
+ try {
+ const metadata = await parseFile(sourceAbsolutePath);
+ const sourceBitrate = metadata.format.bitrate
+ ? Math.round(metadata.format.bitrate / 1000)
+ : null;
+
+ if (sourceBitrate && sourceBitrate <= targetBitrate) {
+ console.log(
+ `[STREAM] Source bitrate (${sourceBitrate}kbps) <= target (${targetBitrate}kbps), serving original`
+ );
+ return {
+ filePath: sourceAbsolutePath,
+ mimeType: this.getMimeType(sourceAbsolutePath),
+ };
+ }
+ } catch (err) {
+ console.warn(
+ `[STREAM] Failed to read source metadata, will transcode anyway:`,
+ err
+ );
+ }
+ }
+
+ // Need to transcode - check cache size first
+ const currentSize = await this.getCacheSize();
+ if (currentSize > this.transcodeCacheMaxGb * 0.9) {
+ console.log(
+ `[STREAM] Cache near full (${currentSize.toFixed(
+ 2
+ )}GB), evicting to 80%...`
+ );
+ await this.evictCache(this.transcodeCacheMaxGb * 0.8);
+ }
+
+ // Transcode to cache
+ console.log(
+ `[STREAM] Transcoding to ${quality} quality: ${sourceAbsolutePath}`
+ );
+ const transcodedPath = await this.transcodeToCache(
+ trackId,
+ quality,
+ sourceAbsolutePath,
+ sourceModified
+ );
+
+ return {
+ filePath: transcodedPath,
+ mimeType: "audio/mpeg",
+ };
+ }
+
+ /**
+ * Get cached transcode if it exists and is valid
+ */
+ private async getCachedTranscode(
+ trackId: string,
+ quality: Quality,
+ sourceModified: Date
+ ): Promise {
+ const cached = await prisma.transcodedFile.findFirst({
+ where: {
+ trackId,
+ quality,
+ },
+ });
+
+ if (!cached) return null;
+
+ // Invalidate if source file was modified after transcode was created
+ if (cached.sourceModified < sourceModified) {
+ console.log(
+ `[STREAM] Cache stale for track ${trackId}, removing...`
+ );
+ await prisma.transcodedFile.delete({ where: { id: cached.id } });
+
+ // Delete file from disk
+ const cachePath = path.join(
+ this.transcodeCachePath,
+ cached.cachePath
+ );
+ await fs.promises.unlink(cachePath).catch(() => {});
+
+ return null;
+ }
+
+ // Update last accessed time
+ await prisma.transcodedFile.update({
+ where: { id: cached.id },
+ data: { lastAccessed: new Date() },
+ });
+
+ const fullPath = path.join(this.transcodeCachePath, cached.cachePath);
+
+ // Verify file exists
+ if (!fs.existsSync(fullPath)) {
+ console.log(`[STREAM] Cache file missing: ${fullPath}`);
+ await prisma.transcodedFile.delete({ where: { id: cached.id } });
+ return null;
+ }
+
+ return fullPath;
+ }
+
+ /**
+ * Transcode audio file to cache
+ */
+ private async transcodeToCache(
+ trackId: string,
+ quality: Quality,
+ sourcePath: string,
+ sourceModified: Date
+ ): Promise {
+ const settings = QUALITY_SETTINGS[quality];
+ if (!settings.bitrate || !settings.format) {
+ throw new AppError(
+ ErrorCode.INVALID_CONFIG,
+ ErrorCategory.FATAL,
+ `Invalid quality setting: ${quality}`
+ );
+ }
+
+ // Generate cache file path
+ const hash = crypto
+ .createHash("md5")
+ .update(`${trackId}-${quality}`)
+ .digest("hex");
+ const cacheFileName = `${hash}.${settings.format}`;
+ const cachePath = path.join(this.transcodeCachePath, cacheFileName);
+
+ return new Promise((resolve, reject) => {
+ try {
+ ffmpeg(sourcePath)
+ .audioBitrate(settings.bitrate)
+ .audioCodec("libmp3lame")
+ .format(settings.format)
+ .on("error", (err) => {
+ // Check if error is due to missing FFmpeg
+ const errorMsg = err.message.toLowerCase();
+ if (
+ errorMsg.includes("ffmpeg") &&
+ errorMsg.includes("not found")
+ ) {
+ reject(
+ new AppError(
+ ErrorCode.FFMPEG_NOT_FOUND,
+ ErrorCategory.FATAL,
+ "FFmpeg not installed. Please install FFmpeg to enable transcoding.",
+ { trackId, quality }
+ )
+ );
+ } else {
+ reject(
+ new AppError(
+ ErrorCode.TRANSCODE_FAILED,
+ ErrorCategory.RECOVERABLE,
+ `Transcoding failed: ${err.message}`,
+ { trackId, quality, source: sourcePath }
+ )
+ );
+ }
+ })
+ .on("end", async () => {
+ try {
+ // Get file size
+ const stats = await fs.promises.stat(cachePath);
+
+ // Save to database
+ await prisma.transcodedFile.create({
+ data: {
+ trackId,
+ quality,
+ cachePath: cacheFileName,
+ cacheSize: stats.size,
+ sourceModified,
+ lastAccessed: new Date(),
+ },
+ });
+
+ console.log(
+ `[STREAM] Transcode complete: ${cacheFileName} (${(
+ stats.size /
+ 1024 /
+ 1024
+ ).toFixed(2)}MB)`
+ );
+ resolve(cachePath);
+ } catch (err: any) {
+ reject(
+ new AppError(
+ ErrorCode.DB_QUERY_ERROR,
+ ErrorCategory.RECOVERABLE,
+ `Failed to save transcode record: ${err.message}`,
+ { trackId, quality }
+ )
+ );
+ }
+ })
+ .save(cachePath);
+ } catch (err: any) {
+ reject(
+ new AppError(
+ ErrorCode.FFMPEG_NOT_FOUND,
+ ErrorCategory.FATAL,
+ "FFmpeg not available. Please install FFmpeg to enable transcoding.",
+ { trackId, quality }
+ )
+ );
+ }
+ });
+ }
+
+ /**
+ * Get total cache size in GB
+ */
+ async getCacheSize(): Promise {
+ const cached = await prisma.transcodedFile.findMany({
+ select: { cacheSize: true },
+ });
+ const totalBytes = cached.reduce((sum, f) => sum + f.cacheSize, 0);
+ return totalBytes / (1024 * 1024 * 1024);
+ }
+
+ /**
+ * Evict cache using LRU until size is below target
+ */
+ async evictCache(targetGb: number): Promise {
+ console.log(`[CACHE] Starting eviction, target: ${targetGb}GB`);
+
+ let currentSize = await this.getCacheSize();
+ console.log(`[CACHE] Current size: ${currentSize.toFixed(2)}GB`);
+
+ if (currentSize <= targetGb) {
+ console.log("[CACHE] Below target, no eviction needed");
+ return;
+ }
+
+ // Get all cached files sorted by last accessed (oldest first)
+ const cached = await prisma.transcodedFile.findMany({
+ orderBy: { lastAccessed: "asc" },
+ });
+
+ let evicted = 0;
+ for (const file of cached) {
+ if (currentSize <= targetGb) break;
+
+ // Delete file from disk
+ const fullPath = path.join(this.transcodeCachePath, file.cachePath);
+ try {
+ await fs.promises.unlink(fullPath);
+ } catch (err) {
+ console.warn(`[CACHE] Failed to delete ${fullPath}:`, err);
+ }
+
+ // Delete from database
+ await prisma.transcodedFile.delete({ where: { id: file.id } });
+
+ currentSize -= file.cacheSize / (1024 * 1024 * 1024);
+ evicted++;
+ }
+
+ console.log(
+ `[CACHE] Evicted ${evicted} files, new size: ${currentSize.toFixed(
+ 2
+ )}GB`
+ );
+ }
+
+ /**
+ * Get MIME type from file extension
+ */
+ getMimeType(filePath: string): string {
+ const ext = path.extname(filePath).toLowerCase();
+ const mimeTypes: Record = {
+ ".mp3": "audio/mpeg",
+ ".flac": "audio/flac",
+ ".m4a": "audio/mp4",
+ ".aac": "audio/aac",
+ ".ogg": "audio/ogg",
+ ".opus": "audio/opus",
+ ".wav": "audio/wav",
+ ".wma": "audio/x-ms-wma",
+ ".ape": "audio/x-ape",
+ ".wv": "audio/x-wavpack",
+ };
+ return mimeTypes[ext] || "audio/mpeg";
+ }
+
+ /**
+ * Cleanup resources
+ */
+ destroy(): void {
+ if (this.evictionInterval) {
+ clearInterval(this.evictionInterval);
+ this.evictionInterval = null;
+ }
+ }
+}
diff --git a/backend/src/services/audiobookCache.ts b/backend/src/services/audiobookCache.ts
new file mode 100644
index 0000000..9656d16
--- /dev/null
+++ b/backend/src/services/audiobookCache.ts
@@ -0,0 +1,416 @@
+import { audiobookshelfService } from "./audiobookshelf";
+import { prisma } from "../utils/db";
+import fs from "fs/promises";
+import path from "path";
+import { config } from "../config";
+
+/**
+ * Service to sync audiobooks from Audiobookshelf and cache them locally
+ * This allows us to serve audiobook metadata from our database instead of hitting
+ * the Audiobookshelf API every time, dramatically improving performance
+ */
+
+interface SyncResult {
+ synced: number;
+ failed: number;
+ skipped: number;
+ errors: string[];
+}
+
+export class AudiobookCacheService {
+ private coverCacheDir: string;
+
+ constructor() {
+ // Store covers in: /cover-cache/audiobooks/
+ this.coverCacheDir = path.join(
+ config.music.musicPath,
+ "cover-cache",
+ "audiobooks"
+ );
+ }
+
+ /**
+ * Sync all audiobooks from Audiobookshelf to our database
+ */
+ async syncAll(): Promise {
+ const result: SyncResult = {
+ synced: 0,
+ failed: 0,
+ skipped: 0,
+ errors: [],
+ };
+
+ try {
+ console.log(" Starting audiobook sync from Audiobookshelf...");
+
+ // Ensure cover cache directory exists
+ await fs.mkdir(this.coverCacheDir, { recursive: true });
+
+ // Fetch all audiobooks from Audiobookshelf
+ const audiobooks = await audiobookshelfService.getAllAudiobooks();
+
+ console.log(
+ `[AUDIOBOOK] Found ${audiobooks.length} audiobooks in Audiobookshelf`
+ );
+
+ for (const book of audiobooks) {
+ try {
+ await this.syncAudiobook(book);
+ result.synced++;
+ // Extract title and author from nested structure for logging
+ const metadata = book.media?.metadata || book;
+ const title =
+ metadata.title || book.title || "Unknown Title";
+ const author =
+ metadata.authorName ||
+ metadata.author ||
+ book.author ||
+ "Unknown Author";
+ console.log(` Synced: ${title} by ${author}`);
+ } catch (error: any) {
+ result.failed++;
+ const metadata = book.media?.metadata || book;
+ const title =
+ metadata.title || book.title || "Unknown Title";
+ const errorMsg = `Failed to sync ${title}: ${error.message}`;
+ result.errors.push(errorMsg);
+ console.error(` ✗ ${errorMsg}`);
+ }
+ }
+
+ console.log("\nSync Summary:");
+ console.log(` Synced: ${result.synced}`);
+ console.log(` Failed: ${result.failed}`);
+ console.log(` Skipped: ${result.skipped}`);
+
+ if (result.errors.length > 0) {
+ console.log("\n[ERRORS]:");
+ result.errors.forEach((err) => console.log(` - ${err}`));
+ }
+
+ return result;
+ } catch (error: any) {
+ console.error(" Audiobook sync failed:", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Sync a single audiobook
+ */
+ private async syncAudiobook(book: any): Promise {
+ // Extract metadata from Audiobookshelf API response structure
+ // The API returns: { id, media: { metadata: { title, author, ... } } }
+ const metadata = book.media?.metadata || book;
+ const title = metadata.title || book.title;
+
+ // Skip if no title (invalid audiobook data)
+ if (!title) {
+ console.warn(` Skipping audiobook ${book.id} - missing title`);
+ return;
+ }
+
+ // Extract additional fields from API response
+ const author = metadata.authorName || metadata.author || null;
+ const narrator = metadata.narratorName || metadata.narrator || null;
+ const description = metadata.description || null;
+ const publishedYear = metadata.publishedYear
+ ? parseInt(metadata.publishedYear)
+ : null;
+ const publisher = metadata.publisher || null;
+ const isbn = metadata.isbn || null;
+ const asin = metadata.asin || null;
+ const language = metadata.language || null;
+ const genres = metadata.genres || [];
+ const tags = book.tags || [];
+ const duration = book.media?.duration || null;
+ const numTracks = book.media?.numTracks || null;
+ const numChapters = book.media?.numChapters || null;
+ const size = book.size ? BigInt(book.size) : null;
+ const libraryId = book.libraryId || null;
+
+ // Get cover path - Audiobookshelf uses media.coverPath
+ const coverPath = book.media?.coverPath || null;
+
+ // Build full cover URL for download (needs to be absolute URL with base)
+ const coverUrl = coverPath ? `items/${book.id}/cover` : null;
+
+ // Series info - Audiobookshelf returns seriesName as a string like "Series Name #2"
+ // We need to parse this to extract the series name and sequence number
+ let series: string | null = null;
+ let seriesSequence: string | null = null;
+
+ if (metadata.seriesName && typeof metadata.seriesName === "string") {
+ const seriesStr = metadata.seriesName.trim();
+
+ // Try to extract sequence from patterns like:
+ // "Series Name #1", "Series Name #2", "Series Name Book 1", "Series Name, Book 1"
+ const sequencePatterns = [
+ /^(.+?)\s*#(\d+(?:\.\d+)?)\s*$/, // "Series Name #1" or "Series Name #1.5"
+ /^(.+?)\s*,?\s*Book\s*(\d+(?:\.\d+)?)\s*$/i, // "Series Name Book 1" or "Series Name, Book 1"
+ /^(.+?)\s*,?\s*Vol\.?\s*(\d+(?:\.\d+)?)\s*$/i, // "Series Name Vol 1" or "Series Name, Vol. 1"
+ /^(.+?)\s*\((\d+(?:\.\d+)?)\)\s*$/, // "Series Name (1)"
+ ];
+
+ let matched = false;
+ for (const pattern of sequencePatterns) {
+ const match = seriesStr.match(pattern);
+ if (match) {
+ series = match[1].trim();
+ seriesSequence = match[2];
+ matched = true;
+ break;
+ }
+ }
+
+ // If no sequence pattern matched, use the whole string as series name
+ if (!matched && seriesStr) {
+ series = seriesStr;
+ seriesSequence = null;
+ }
+ }
+
+ // Fallback: check metadata.series array/object format
+ if (!series) {
+ if (Array.isArray(metadata.series) && metadata.series.length > 0) {
+ series = metadata.series[0]?.name || null;
+ seriesSequence =
+ metadata.series[0]?.sequence?.toString() || null;
+ } else if (
+ typeof metadata.series === "object" &&
+ metadata.series !== null
+ ) {
+ series = metadata.series.name || null;
+ seriesSequence = metadata.series.sequence?.toString() || null;
+ }
+ }
+
+ // Log series info for debugging (only for first few books)
+ if (series) {
+ console.log(
+ ` [Series] "${title}" -> "${series}" #${
+ seriesSequence || "?"
+ }`
+ );
+ }
+
+ // Download cover image if available - need to construct full URL
+ let localCoverPath: string | null = null;
+ if (coverUrl) {
+ // Get the Audiobookshelf base URL from the service
+ const fullCoverUrl = await this.getFullCoverUrl(coverUrl);
+ if (fullCoverUrl) {
+ localCoverPath = await this.downloadCover(
+ book.id,
+ fullCoverUrl
+ );
+ }
+ }
+
+ // Upsert to database
+ await prisma.audiobook.upsert({
+ where: { id: book.id },
+ create: {
+ id: book.id,
+ title,
+ author,
+ narrator,
+ description,
+ publishedYear,
+ publisher,
+ series,
+ seriesSequence,
+ duration,
+ numTracks,
+ numChapters,
+ size,
+ isbn,
+ asin,
+ language,
+ genres,
+ tags,
+ localCoverPath,
+ coverUrl,
+ audioUrl: book.id,
+ libraryId,
+ lastSyncedAt: new Date(),
+ },
+ update: {
+ title,
+ author,
+ narrator,
+ description,
+ publishedYear,
+ publisher,
+ series,
+ seriesSequence,
+ duration,
+ numTracks,
+ numChapters,
+ size,
+ isbn,
+ asin,
+ language,
+ genres,
+ tags,
+ localCoverPath: localCoverPath || undefined,
+ coverUrl,
+ audioUrl: book.id,
+ libraryId,
+ lastSyncedAt: new Date(),
+ },
+ });
+ }
+
+ /**
+ * Get full Audiobookshelf cover URL by prepending base URL
+ */
+ private async getFullCoverUrl(
+ relativePath: string
+ ): Promise {
+ try {
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+ const settings = await getSystemSettings();
+
+ if (settings?.audiobookshelfUrl) {
+ const baseUrl = settings.audiobookshelfUrl.replace(/\/$/, "");
+ return `${baseUrl}/api/${relativePath}`;
+ }
+
+ return null;
+ } catch (error: any) {
+ console.error(
+ "Failed to get Audiobookshelf base URL:",
+ error.message
+ );
+ return null;
+ }
+ }
+
+ /**
+ * Download a cover image and save it locally
+ */
+ private async downloadCover(
+ audiobookId: string,
+ coverUrl: string
+ ): Promise {
+ try {
+ // Get API key for authentication
+ const { getSystemSettings } = await import(
+ "../utils/systemSettings"
+ );
+ const settings = await getSystemSettings();
+
+ if (!settings?.audiobookshelfApiKey) {
+ throw new Error("Audiobookshelf API key not configured");
+ }
+
+ const response = await fetch(coverUrl, {
+ headers: {
+ Authorization: `Bearer ${settings.audiobookshelfApiKey}`,
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `HTTP ${response.status}: ${response.statusText}`
+ );
+ }
+
+ const buffer = await response.arrayBuffer();
+ const fileName = `${audiobookId}.jpg`;
+ const filePath = path.join(this.coverCacheDir, fileName);
+
+ await fs.writeFile(filePath, Buffer.from(buffer));
+
+ return filePath;
+ } catch (error: any) {
+ console.error(
+ `Failed to download cover for ${audiobookId}:`,
+ error.message
+ );
+ return null as any; // Return null if download fails
+ }
+ }
+
+ /**
+ * Get a single audiobook from cache or sync it
+ */
+ async getAudiobook(audiobookId: string): Promise {
+ // Try to get from database first
+ let audiobook = await prisma.audiobook.findUnique({
+ where: { id: audiobookId },
+ });
+
+ // If not in cache or stale (> 7 days), try to sync it
+ if (
+ !audiobook ||
+ audiobook.lastSyncedAt <
+ new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
+ ) {
+ console.log(
+ `[AUDIOBOOK] Audiobook ${audiobookId} not cached or stale, syncing...`
+ );
+ try {
+ const book = await audiobookshelfService.getAudiobook(
+ audiobookId
+ );
+ await this.syncAudiobook(book);
+ audiobook = await prisma.audiobook.findUnique({
+ where: { id: audiobookId },
+ });
+ } catch (syncError: any) {
+ console.warn(
+ ` Failed to sync audiobook ${audiobookId} from Audiobookshelf:`,
+ syncError.message
+ );
+ // If we have stale cached data, return it anyway
+ if (audiobook) {
+ console.log(
+ ` Using stale cached data for ${audiobookId}`
+ );
+ } else {
+ // No cached data and sync failed - throw error
+ throw new Error(
+ `Audiobook not found in cache and sync failed: ${syncError.message}`
+ );
+ }
+ }
+ }
+
+ return audiobook;
+ }
+
+ /**
+ * Clean up old cached covers that are no longer in database
+ */
+ async cleanupOrphanedCovers(): Promise {
+ const audiobooks = await prisma.audiobook.findMany({
+ select: { localCoverPath: true },
+ });
+
+ const validCoverPaths = new Set(
+ audiobooks
+ .filter((a) => a.localCoverPath)
+ .map((a) => path.basename(a.localCoverPath!))
+ );
+
+ let deleted = 0;
+ const files = await fs.readdir(this.coverCacheDir);
+
+ for (const file of files) {
+ if (!validCoverPaths.has(file)) {
+ await fs.unlink(path.join(this.coverCacheDir, file));
+ deleted++;
+ console.log(` [DELETE] Deleted orphaned cover: ${file}`);
+ }
+ }
+
+ return deleted;
+ }
+}
+
+// Export singleton instance
+export const audiobookCacheService = new AudiobookCacheService();
diff --git a/backend/src/services/audiobookshelf.ts b/backend/src/services/audiobookshelf.ts
new file mode 100644
index 0000000..436af78
--- /dev/null
+++ b/backend/src/services/audiobookshelf.ts
@@ -0,0 +1,335 @@
+import axios, { AxiosInstance } from "axios";
+import { getSystemSettings } from "../utils/systemSettings";
+
+/**
+ * Audiobookshelf API Service
+ * Handles all interactions with the Audiobookshelf server
+ */
+class AudiobookshelfService {
+ private client: AxiosInstance | null = null;
+ private baseUrl: string | null = null;
+ private apiKey: string | null = null;
+ private initialized = false;
+ private podcastCache: { items: any[]; expiresAt: number } | null = null;
+ private readonly PODCAST_CACHE_TTL_MS = 5 * 60 * 1000;
+
+ private async ensureInitialized() {
+ if (this.initialized && this.client) return;
+
+ try {
+ // Try to get from database first
+ const settings = await getSystemSettings();
+
+ // Check if Audiobookshelf is explicitly disabled
+ if (settings && settings.audiobookshelfEnabled === false) {
+ throw new Error("Audiobookshelf is disabled in settings");
+ }
+
+ if (
+ settings?.audiobookshelfEnabled &&
+ settings?.audiobookshelfUrl &&
+ settings?.audiobookshelfApiKey
+ ) {
+ this.baseUrl = settings.audiobookshelfUrl.replace(/\/$/, ""); // Remove trailing slash
+ this.apiKey = settings.audiobookshelfApiKey;
+ this.client = axios.create({
+ baseURL: this.baseUrl,
+ headers: {
+ Authorization: `Bearer ${this.apiKey}`,
+ },
+ timeout: 30000, // 30 seconds for remote server
+ });
+ console.log("Audiobookshelf configured from database");
+ this.initialized = true;
+ return;
+ }
+ } catch (error: any) {
+ if (error.message === "Audiobookshelf is disabled in settings") {
+ throw error;
+ }
+ console.log(
+ " Could not load Audiobookshelf from database, checking .env"
+ );
+ }
+
+ // Fallback to .env
+ if (
+ process.env.AUDIOBOOKSHELF_URL &&
+ process.env.AUDIOBOOKSHELF_API_KEY
+ ) {
+ this.baseUrl = process.env.AUDIOBOOKSHELF_URL.replace(/\/$/, "");
+ this.apiKey = process.env.AUDIOBOOKSHELF_API_KEY;
+ this.client = axios.create({
+ baseURL: this.baseUrl,
+ headers: {
+ Authorization: `Bearer ${this.apiKey}`,
+ },
+ timeout: 30000, // 30 seconds for remote server
+ });
+ console.log("Audiobookshelf configured from .env");
+ this.initialized = true;
+ } else {
+ throw new Error("Audiobookshelf not configured");
+ }
+ }
+
+ /**
+ * Test connection to Audiobookshelf
+ */
+ async ping(): Promise {
+ try {
+ await this.ensureInitialized();
+ const response = await this.client!.get("/api/libraries");
+ return response.status === 200;
+ } catch (error) {
+ console.error("Audiobookshelf connection failed:", error);
+ return false;
+ }
+ }
+
+ /**
+ * Get all libraries from Audiobookshelf
+ */
+ async getLibraries() {
+ await this.ensureInitialized();
+ const response = await this.client!.get("/api/libraries");
+ return response.data.libraries || [];
+ }
+
+ /**
+ * Get all audiobooks from a specific library
+ */
+ async getLibraryItems(libraryId: string) {
+ await this.ensureInitialized();
+ const response = await this.client!.get(
+ `/api/libraries/${libraryId}/items`
+ );
+ return response.data.results || [];
+ }
+
+ /**
+ * Get all audiobooks from all libraries
+ */
+ async getAllAudiobooks() {
+ await this.ensureInitialized();
+ const libraries = await this.getLibraries();
+
+ const allBooks: any[] = [];
+ for (const library of libraries) {
+ if (library.mediaType === "book") {
+ // Only get audiobook libraries
+ const items = await this.getLibraryItems(library.id);
+
+ // DEBUG: Log the structure of the first item with series
+ if (items.length > 0) {
+ const itemsWithSeries = items.filter((item: any) =>
+ item.media?.metadata?.series || item.media?.metadata?.seriesName
+ );
+ if (itemsWithSeries.length > 0) {
+ console.log(
+ "[AUDIOBOOKSHELF DEBUG] Sample item WITH series:",
+ JSON.stringify(itemsWithSeries[0], null, 2).substring(0, 2000)
+ );
+ } else {
+ console.log(
+ "[AUDIOBOOKSHELF DEBUG] No items with series found! Sample item:",
+ JSON.stringify(items[0], null, 2).substring(0, 1000)
+ );
+ }
+ }
+
+ allBooks.push(...items);
+ }
+ }
+
+ return allBooks;
+ }
+
+ /**
+ * Get all podcasts from all libraries
+ */
+ async getAllPodcasts(forceRefresh = false) {
+ await this.ensureInitialized();
+
+ if (
+ !forceRefresh &&
+ this.podcastCache &&
+ this.podcastCache.expiresAt > Date.now()
+ ) {
+ return this.podcastCache.items;
+ }
+
+ const libraries = await this.getLibraries();
+ const podcastLibraries = libraries.filter(
+ (library: any) => library.mediaType === "podcast"
+ );
+
+ const libraryResults = await Promise.all(
+ podcastLibraries.map(async (library: any) => {
+ try {
+ return await this.getLibraryItems(library.id);
+ } catch (error) {
+ console.error(
+ `Audiobookshelf: failed to load podcast library ${library.id}`,
+ error
+ );
+ return [];
+ }
+ })
+ );
+
+ const allPodcasts = libraryResults.flat();
+
+ this.podcastCache = {
+ items: allPodcasts,
+ expiresAt: Date.now() + this.PODCAST_CACHE_TTL_MS,
+ };
+
+ return allPodcasts;
+ }
+
+ /**
+ * Get a specific audiobook by ID
+ */
+ async getAudiobook(audiobookId: string) {
+ await this.ensureInitialized();
+ const response = await this.client!.get(
+ `/api/items/${audiobookId}?expanded=1`
+ );
+ return response.data;
+ }
+
+ /**
+ * Get a specific podcast by ID (alias for getAudiobook since API is the same)
+ */
+ async getPodcast(podcastId: string) {
+ return this.getAudiobook(podcastId);
+ }
+
+ /**
+ * Get user's progress for an audiobook
+ */
+ async getProgress(audiobookId: string) {
+ await this.ensureInitialized();
+ const response = await this.client!.get(
+ `/api/me/progress/${audiobookId}`
+ );
+ return response.data;
+ }
+
+ /**
+ * Update user's progress for an audiobook
+ */
+ async updateProgress(
+ audiobookId: string,
+ currentTime: number,
+ duration: number,
+ isFinished: boolean = false
+ ) {
+ await this.ensureInitialized();
+ const response = await this.client!.patch(
+ `/api/me/progress/${audiobookId}`,
+ {
+ currentTime,
+ duration,
+ isFinished,
+ }
+ );
+ return response.data;
+ }
+
+ /**
+ * Get stream URL for an audiobook
+ */
+ async getStreamUrl(audiobookId: string): Promise {
+ await this.ensureInitialized();
+ return `${this.baseUrl}/api/items/${audiobookId}/play`;
+ }
+
+ /**
+ * Stream an audiobook with authentication
+ * Returns a readable stream that can be piped to the response
+ */
+ async streamAudiobook(audiobookId: string, rangeHeader?: string) {
+ await this.ensureInitialized();
+
+ // First, get the audiobook to find the track file
+ const audiobook = await this.getAudiobook(audiobookId);
+
+ // Get the first track's content URL
+ const firstTrack = audiobook.media?.tracks?.[0];
+ if (!firstTrack || !firstTrack.contentUrl) {
+ throw new Error("No audio track found for this audiobook");
+ }
+
+ // Build request headers
+ const headers: Record = {};
+ if (rangeHeader) {
+ headers["Range"] = rangeHeader;
+ }
+
+ // The contentUrl format is: /api/items/{id}/file/{ino}
+ const response = await this.client!.get(firstTrack.contentUrl, {
+ responseType: "stream",
+ timeout: 0, // No timeout for streaming
+ headers,
+ // Don't throw on 206 Partial Content
+ validateStatus: (status) => status >= 200 && status < 300,
+ });
+
+ return {
+ stream: response.data,
+ headers: response.headers,
+ status: response.status,
+ };
+ }
+
+ /**
+ * Stream a podcast episode with authentication
+ * For podcasts, we need to get a specific episode ID
+ */
+ async streamPodcastEpisode(podcastId: string, episodeId: string) {
+ await this.ensureInitialized();
+
+ // Get the podcast to find the episode
+ const podcast = await this.getPodcast(podcastId);
+ const episode = podcast.media?.episodes?.find(
+ (ep: any) => ep.id === episodeId
+ );
+
+ if (!episode) {
+ throw new Error("Episode not found");
+ }
+
+ // Podcast episodes use audioTrack.contentUrl, not audioFile.contentUrl
+ const contentUrl =
+ episode.audioTrack?.contentUrl || episode.audioFile?.contentUrl;
+
+ if (!contentUrl) {
+ throw new Error("No audio file found for this episode");
+ }
+
+ const response = await this.client!.get(contentUrl, {
+ responseType: "stream",
+ timeout: 0,
+ });
+
+ return {
+ stream: response.data,
+ headers: response.headers,
+ };
+ }
+
+ /**
+ * Search audiobooks
+ */
+ async searchAudiobooks(query: string) {
+ await this.ensureInitialized();
+ const response = await this.client!.get(
+ `/api/search/books?q=${encodeURIComponent(query)}`
+ );
+ return response.data.book || [];
+ }
+}
+
+export const audiobookshelfService = new AudiobookshelfService();
diff --git a/backend/src/services/coverArt.ts b/backend/src/services/coverArt.ts
new file mode 100644
index 0000000..af790f7
--- /dev/null
+++ b/backend/src/services/coverArt.ts
@@ -0,0 +1,67 @@
+import axios from "axios";
+import { redisClient } from "../utils/redis";
+import { rateLimiter } from "./rateLimiter";
+
+class CoverArtService {
+ private readonly baseUrl = "https://coverartarchive.org";
+
+ async getCoverArt(rgMbid: string): Promise {
+ const cacheKey = `caa:${rgMbid}`;
+
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached === "NOT_FOUND") return null; // Cached negative result
+ if (cached) return cached;
+ } catch (err) {
+ console.warn("Redis get error:", err);
+ }
+
+ try {
+ // Use rate limiter to prevent overwhelming Cover Art Archive
+ const response = await rateLimiter.execute("coverart", () =>
+ axios.get(`${this.baseUrl}/release-group/${rgMbid}`, {
+ timeout: 5000,
+ })
+ );
+
+ const images = response.data.images || [];
+ const frontImage =
+ images.find((img: any) => img.front) || images[0];
+
+ if (frontImage) {
+ const coverUrl =
+ frontImage.thumbnails?.large || frontImage.image;
+
+ try {
+ await redisClient.setEx(cacheKey, 2592000, coverUrl); // 30 days
+ } catch (err) {
+ console.warn("Redis set error:", err);
+ }
+
+ return coverUrl;
+ }
+
+ // No front image found - cache negative result
+ try {
+ await redisClient.setEx(cacheKey, 604800, "NOT_FOUND"); // 7 days
+ } catch (err) {
+ // Ignore
+ }
+ } catch (error: any) {
+ if (error.response?.status === 404) {
+ // No cover art available - cache the negative result
+ try {
+ await redisClient.setEx(cacheKey, 604800, "NOT_FOUND"); // 7 days
+ } catch (err) {
+ // Ignore
+ }
+ return null;
+ }
+ console.error(`Cover art error for ${rgMbid}:`, error.message);
+ }
+
+ return null;
+ }
+}
+
+export const coverArtService = new CoverArtService();
diff --git a/backend/src/services/coverArtExtractor.ts b/backend/src/services/coverArtExtractor.ts
new file mode 100644
index 0000000..e460739
--- /dev/null
+++ b/backend/src/services/coverArtExtractor.ts
@@ -0,0 +1,75 @@
+import * as fs from "fs";
+import * as path from "path";
+import * as crypto from "crypto";
+import { parseFile } from "music-metadata";
+
+export class CoverArtExtractor {
+ private coverCachePath: string;
+
+ constructor(coverCachePath: string) {
+ this.coverCachePath = coverCachePath;
+
+ // Ensure cache directory exists
+ if (!fs.existsSync(this.coverCachePath)) {
+ fs.mkdirSync(this.coverCachePath, { recursive: true });
+ }
+ }
+
+ /**
+ * Extract cover art from audio file and save to cache
+ * Returns relative path to cached cover art, or null if none found
+ */
+ async extractCoverArt(
+ audioFilePath: string,
+ albumId: string
+ ): Promise {
+ try {
+ // Check if already cached
+ const cacheFileName = `${albumId}.jpg`;
+ const cachePath = path.join(this.coverCachePath, cacheFileName);
+
+ if (fs.existsSync(cachePath)) {
+ return cacheFileName;
+ }
+
+ // Parse audio file metadata
+ const metadata = await parseFile(audioFilePath);
+
+ // Get embedded picture
+ const picture = metadata.common.picture?.[0];
+ if (!picture) {
+ return null;
+ }
+
+ // Save to cache
+ await fs.promises.writeFile(cachePath, picture.data);
+
+ console.log(
+ `[COVER-ART] Extracted cover art from ${path.basename(audioFilePath)}: ${cacheFileName}`
+ );
+
+ return cacheFileName;
+ } catch (err) {
+ console.error(
+ `[COVER-ART] Failed to extract from ${audioFilePath}:`,
+ err
+ );
+ return null;
+ }
+ }
+
+ /**
+ * Get cover art URL for album
+ * Returns relative path if available, or null
+ */
+ async getCoverArtPath(albumId: string): Promise {
+ const cacheFileName = `${albumId}.jpg`;
+ const cachePath = path.join(this.coverCachePath, cacheFileName);
+
+ if (fs.existsSync(cachePath)) {
+ return cacheFileName;
+ }
+
+ return null;
+ }
+}
diff --git a/backend/src/services/dataCache.ts b/backend/src/services/dataCache.ts
new file mode 100644
index 0000000..39bfce1
--- /dev/null
+++ b/backend/src/services/dataCache.ts
@@ -0,0 +1,390 @@
+/**
+ * DataCacheService - Unified data access with consistent caching pattern
+ *
+ * Pattern: DB first -> Redis fallback -> API fetch -> save to both
+ *
+ * This ensures:
+ * - DB is the source of truth
+ * - Redis provides fast reads
+ * - API calls only happen when data doesn't exist
+ * - All fetched data is persisted for future use
+ */
+
+import { prisma } from "../utils/db";
+import { redisClient } from "../utils/redis";
+import { fanartService } from "./fanart";
+import { deezerService } from "./deezer";
+import { lastFmService } from "./lastfm";
+import { coverArtService } from "./coverArt";
+
+// Cache TTLs
+const ARTIST_IMAGE_TTL = 7 * 24 * 60 * 60; // 7 days
+const ALBUM_COVER_TTL = 30 * 24 * 60 * 60; // 30 days
+const NEGATIVE_CACHE_TTL = 24 * 60 * 60; // 1 day for "not found" results
+
+class DataCacheService {
+ /**
+ * Get artist hero image with unified caching
+ * Order: DB -> Redis -> Fanart.tv -> Deezer -> Last.fm -> save to both
+ */
+ async getArtistImage(
+ artistId: string,
+ artistName: string,
+ mbid?: string | null
+ ): Promise {
+ const cacheKey = `hero:${artistId}`;
+
+ // 1. Check DB first (source of truth)
+ try {
+ const artist = await prisma.artist.findUnique({
+ where: { id: artistId },
+ select: { heroUrl: true },
+ });
+ if (artist?.heroUrl) {
+ // Also populate Redis for faster future reads
+ this.setRedisCache(cacheKey, artist.heroUrl, ARTIST_IMAGE_TTL);
+ return artist.heroUrl;
+ }
+ } catch (err) {
+ console.warn("[DataCache] DB lookup failed for artist:", artistId);
+ }
+
+ // 2. Check Redis cache
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached === "NOT_FOUND") return null; // Negative cache hit
+ if (cached) {
+ // Sync back to DB if Redis has it but DB doesn't
+ this.updateArtistHeroUrl(artistId, cached);
+ return cached;
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ // 3. Fetch from external APIs
+ const heroUrl = await this.fetchArtistImage(artistName, mbid);
+
+ // 4. Save to both DB and Redis
+ if (heroUrl) {
+ await this.updateArtistHeroUrl(artistId, heroUrl);
+ this.setRedisCache(cacheKey, heroUrl, ARTIST_IMAGE_TTL);
+ } else {
+ // Cache negative result to avoid repeated API calls
+ this.setRedisCache(cacheKey, "NOT_FOUND", NEGATIVE_CACHE_TTL);
+ }
+
+ return heroUrl;
+ }
+
+ /**
+ * Get album cover with unified caching
+ * Order: DB -> Redis -> Cover Art Archive -> save to both
+ */
+ async getAlbumCover(
+ albumId: string,
+ rgMbid: string
+ ): Promise {
+ const cacheKey = `album-cover:${albumId}`;
+
+ // 1. Check DB first
+ try {
+ const album = await prisma.album.findUnique({
+ where: { id: albumId },
+ select: { coverUrl: true },
+ });
+ if (album?.coverUrl) {
+ this.setRedisCache(cacheKey, album.coverUrl, ALBUM_COVER_TTL);
+ return album.coverUrl;
+ }
+ } catch (err) {
+ console.warn("[DataCache] DB lookup failed for album:", albumId);
+ }
+
+ // 2. Check Redis cache
+ try {
+ const cached = await redisClient.get(cacheKey);
+ if (cached === "NOT_FOUND") return null;
+ if (cached) {
+ this.updateAlbumCoverUrl(albumId, cached);
+ return cached;
+ }
+ } catch (err) {
+ // Redis errors are non-critical
+ }
+
+ // 3. Fetch from Cover Art Archive
+ const coverUrl = await coverArtService.getCoverArt(rgMbid);
+
+ // 4. Save to both DB and Redis
+ if (coverUrl) {
+ await this.updateAlbumCoverUrl(albumId, coverUrl);
+ this.setRedisCache(cacheKey, coverUrl, ALBUM_COVER_TTL);
+ } else {
+ this.setRedisCache(cacheKey, "NOT_FOUND", NEGATIVE_CACHE_TTL);
+ }
+
+ return coverUrl;
+ }
+
+ /**
+ * Get track cover (uses album cover)
+ */
+ async getTrackCover(
+ trackId: string,
+ albumId: string,
+ rgMbid?: string | null
+ ): Promise {
+ if (!rgMbid) {
+ // Try to get album's rgMbid from DB
+ const album = await prisma.album.findUnique({
+ where: { id: albumId },
+ select: { rgMbid: true, coverUrl: true },
+ });
+ if (album?.coverUrl) return album.coverUrl;
+ if (album?.rgMbid) rgMbid = album.rgMbid;
+ }
+
+ if (!rgMbid) return null;
+
+ return this.getAlbumCover(albumId, rgMbid);
+ }
+
+ /**
+ * Batch get artist images - for list views
+ * Only returns what's already cached, doesn't make API calls
+ */
+ async getArtistImagesBatch(
+ artists: Array<{ id: string; heroUrl?: string | null }>
+ ): Promise