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 + +[![Docker Image](https://img.shields.io/docker/v/chevron7locked/lidify?label=Docker&sort=semver)](https://hub.docker.com/r/chevron7locked/lidify) +[![GitHub Release](https://img.shields.io/github/v/release/Chevron7Locked/lidify?label=Release)](https://github.com/Chevron7Locked/lidify/releases) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](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. + +![Lidify Home Screen](assets/screenshots/desktop-home.png) + +--- + +## 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) + +

+ Library View +

+ +### 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 + +

+ Podcasts +

+ +### 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 + +

+ Audiobooks +

+ +### 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 + +

+ Vibe Overlay +

+

+ Mood Mixer +

+ +### 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 + +

+ Browse Deezer +

+

+ Import Preview +

+ +### 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 Home + Mobile Player + Mobile Library +

+ +--- + +## 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 + +

+ Artist Page +

+

+ Album Page +

+ +### 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 + +

+ Now Playing +

+

+ Settings +

+ +### 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> { + const results = new Map(); + + // First, use any heroUrls already in the data + for (const artist of artists) { + if (artist.heroUrl) { + results.set(artist.id, artist.heroUrl); + } + } + + // For the rest, check Redis cache only (no API calls for list views) + const missingIds = artists + .filter((a) => !results.has(a.id)) + .map((a) => a.id); + + if (missingIds.length > 0) { + try { + const cacheKeys = missingIds.map((id) => `hero:${id}`); + const cached = await redisClient.mGet(cacheKeys); + + missingIds.forEach((id, index) => { + const value = cached[index]; + if (value && value !== "NOT_FOUND") { + results.set(id, value); + } + }); + } catch (err) { + // Redis errors are non-critical + } + } + + return results; + } + + /** + * Batch get album covers - for list views + */ + async getAlbumCoversBatch( + albums: Array<{ id: string; coverUrl?: string | null }> + ): Promise> { + const results = new Map(); + + for (const album of albums) { + if (album.coverUrl) { + results.set(album.id, album.coverUrl); + } + } + + const missingIds = albums + .filter((a) => !results.has(a.id)) + .map((a) => a.id); + + if (missingIds.length > 0) { + try { + const cacheKeys = missingIds.map((id) => `album-cover:${id}`); + const cached = await redisClient.mGet(cacheKeys); + + missingIds.forEach((id, index) => { + const value = cached[index]; + if (value && value !== "NOT_FOUND") { + results.set(id, value); + } + }); + } catch (err) { + // Redis errors are non-critical + } + } + + return results; + } + + /** + * Fetch artist image from external APIs + * Order: Fanart.tv (if MBID) -> Deezer -> Last.fm + */ + private async fetchArtistImage( + artistName: string, + mbid?: string | null + ): Promise { + let heroUrl: string | null = null; + + // Try Fanart.tv first if we have a valid MBID + if (mbid && !mbid.startsWith("temp-")) { + try { + heroUrl = await fanartService.getArtistImage(mbid); + if (heroUrl) { + console.log(`[DataCache] Got image from Fanart.tv for ${artistName}`); + return heroUrl; + } + } catch (err) { + // Fanart.tv failed, continue + } + } + + // Try Deezer + try { + heroUrl = await deezerService.getArtistImage(artistName); + if (heroUrl) { + console.log(`[DataCache] Got image from Deezer for ${artistName}`); + return heroUrl; + } + } catch (err) { + // Deezer failed, continue + } + + // Try Last.fm + try { + const validMbid = mbid && !mbid.startsWith("temp-") ? mbid : undefined; + const lastfmInfo = await lastFmService.getArtistInfo(artistName, validMbid); + + if (lastfmInfo?.image && Array.isArray(lastfmInfo.image)) { + const largestImage = + lastfmInfo.image.find((img: any) => img.size === "extralarge" || img.size === "mega") || + lastfmInfo.image[lastfmInfo.image.length - 1]; + + if (largestImage && largestImage["#text"]) { + // Filter out Last.fm placeholder images + const imageUrl = largestImage["#text"]; + if (!imageUrl.includes("2a96cbd8b46e442fc41c2b86b821562f")) { + console.log(`[DataCache] Got image from Last.fm for ${artistName}`); + return imageUrl; + } + } + } + } catch (err) { + // Last.fm failed + } + + console.log(`[DataCache] No image found for ${artistName}`); + return null; + } + + /** + * Update artist heroUrl in database + */ + private async updateArtistHeroUrl(artistId: string, heroUrl: string): Promise { + try { + await prisma.artist.update({ + where: { id: artistId }, + data: { heroUrl }, + }); + } catch (err) { + console.warn("[DataCache] Failed to update artist heroUrl:", err); + } + } + + /** + * Update album coverUrl in database + */ + private async updateAlbumCoverUrl(albumId: string, coverUrl: string): Promise { + try { + await prisma.album.update({ + where: { id: albumId }, + data: { coverUrl }, + }); + } catch (err) { + console.warn("[DataCache] Failed to update album coverUrl:", err); + } + } + + /** + * Set Redis cache with error handling + */ + private async setRedisCache(key: string, value: string, ttl: number): Promise { + try { + await redisClient.setEx(key, ttl, value); + } catch (err) { + // Redis errors are non-critical + } + } + + /** + * Warm up Redis cache from database + * Called on server startup + */ + async warmupCache(): Promise { + console.log("[DataCache] Warming up Redis cache from database..."); + + try { + // Warm up artist images + const artists = await prisma.artist.findMany({ + where: { heroUrl: { not: null } }, + select: { id: true, heroUrl: true }, + }); + + let artistCount = 0; + for (const artist of artists) { + if (artist.heroUrl) { + await this.setRedisCache(`hero:${artist.id}`, artist.heroUrl, ARTIST_IMAGE_TTL); + artistCount++; + } + } + console.log(`[DataCache] Cached ${artistCount} artist images`); + + // Warm up album covers + const albums = await prisma.album.findMany({ + where: { coverUrl: { not: null } }, + select: { id: true, coverUrl: true }, + }); + + let albumCount = 0; + for (const album of albums) { + if (album.coverUrl) { + await this.setRedisCache(`album-cover:${album.id}`, album.coverUrl, ALBUM_COVER_TTL); + albumCount++; + } + } + console.log(`[DataCache] Cached ${albumCount} album covers`); + + console.log("[DataCache] Cache warmup complete"); + } catch (err) { + console.error("[DataCache] Cache warmup failed:", err); + } + } +} + +export const dataCacheService = new DataCacheService(); + + + + + + + + + + + + + + + diff --git a/backend/src/services/deezer.ts b/backend/src/services/deezer.ts new file mode 100644 index 0000000..6d03229 --- /dev/null +++ b/backend/src/services/deezer.ts @@ -0,0 +1,587 @@ +import axios from "axios"; +import { redisClient } from "../utils/redis"; + +/** + * Deezer Service + * + * Fetches images, previews, and public playlist data from Deezer. + * No authentication required - Deezer's API is completely public. + */ + +const DEEZER_API = "https://api.deezer.com"; + +// ============================================ +// Playlist Types +// ============================================ + +export interface DeezerTrack { + deezerId: string; + title: string; + artist: string; + artistId: string; + album: string; + albumId: string; + durationMs: number; + previewUrl: string | null; + coverUrl: string | null; +} + +export interface DeezerPlaylist { + id: string; + title: string; + description: string | null; + creator: string; + imageUrl: string | null; + trackCount: number; + tracks: DeezerTrack[]; + isPublic: boolean; +} + +export interface DeezerPlaylistPreview { + id: string; + title: string; + description: string | null; + creator: string; + imageUrl: string | null; + trackCount: number; + fans: number; +} + +export interface DeezerRadioStation { + id: string; + title: string; + description: string | null; + imageUrl: string | null; + type: "radio"; +} + +export interface DeezerGenre { + id: number; + name: string; + imageUrl: string | null; +} + +export interface DeezerGenreWithRadios { + id: number; + name: string; + radios: DeezerRadioStation[]; +} + +// ============================================ +// Service Class +// ============================================ + +class DeezerService { + private readonly cachePrefix = "deezer:"; + private readonly cacheTTL = 86400; // 24 hours + + /** + * Get cached value from Redis + */ + private async getCached(key: string): Promise { + try { + return await redisClient.get(`${this.cachePrefix}${key}`); + } catch { + return null; + } + } + + /** + * Set cached value in Redis + */ + private async setCache(key: string, value: string): Promise { + try { + await redisClient.setex(`${this.cachePrefix}${key}`, this.cacheTTL, value); + } catch { + // Ignore cache errors + } + } + + // ============================================ + // Image & Preview Methods (existing functionality) + // ============================================ + + /** + * Search for an artist and get their image URL + */ + async getArtistImage(artistName: string): Promise { + const cacheKey = `artist:${artistName.toLowerCase()}`; + const cached = await this.getCached(cacheKey); + if (cached) return cached === "null" ? null : cached; + + try { + const response = await axios.get(`${DEEZER_API}/search/artist`, { + params: { q: artistName, limit: 1 }, + timeout: 5000, + }); + + const artist = response.data?.data?.[0]; + const imageUrl = artist?.picture_xl || artist?.picture_big || artist?.picture_medium || null; + + await this.setCache(cacheKey, imageUrl || "null"); + return imageUrl; + } catch (error: any) { + console.error(`Deezer artist image error for ${artistName}:`, error.message); + return null; + } + } + + /** + * Search for an album and get its cover art URL + */ + async getAlbumCover(artistName: string, albumName: string): Promise { + const cacheKey = `album:${artistName.toLowerCase()}:${albumName.toLowerCase()}`; + const cached = await this.getCached(cacheKey); + if (cached) return cached === "null" ? null : cached; + + try { + const response = await axios.get(`${DEEZER_API}/search/album`, { + params: { q: `artist:"${artistName}" album:"${albumName}"`, limit: 5 }, + timeout: 5000, + }); + + // Find the best match + const albums = response.data?.data || []; + let bestMatch = albums[0]; + + for (const album of albums) { + if (album.artist?.name?.toLowerCase() === artistName.toLowerCase() && + album.title?.toLowerCase() === albumName.toLowerCase()) { + bestMatch = album; + break; + } + } + + const coverUrl = bestMatch?.cover_xl || bestMatch?.cover_big || bestMatch?.cover_medium || null; + + await this.setCache(cacheKey, coverUrl || "null"); + return coverUrl; + } catch (error: any) { + console.error(`Deezer album cover error for ${artistName} - ${albumName}:`, error.message); + return null; + } + } + + /** + * Get a preview URL for a track + */ + async getTrackPreview(artistName: string, trackName: string): Promise { + const cacheKey = `preview:${artistName.toLowerCase()}:${trackName.toLowerCase()}`; + const cached = await this.getCached(cacheKey); + if (cached) return cached === "null" ? null : cached; + + try { + const response = await axios.get(`${DEEZER_API}/search/track`, { + params: { q: `artist:"${artistName}" track:"${trackName}"`, limit: 1 }, + timeout: 5000, + }); + + const track = response.data?.data?.[0]; + const previewUrl = track?.preview || null; + + await this.setCache(cacheKey, previewUrl || "null"); + return previewUrl; + } catch (error: any) { + console.error(`Deezer track preview error for ${artistName} - ${trackName}:`, error.message); + return null; + } + } + + // ============================================ + // Playlist Methods (new functionality) + // ============================================ + + /** + * Parse a Deezer URL and extract the type and ID + */ + parseUrl(url: string): { type: "playlist" | "album" | "track"; id: string } | null { + const playlistMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/); + if (playlistMatch) { + return { type: "playlist", id: playlistMatch[1] }; + } + + const albumMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/); + if (albumMatch) { + return { type: "album", id: albumMatch[1] }; + } + + const trackMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?track\/(\d+)/); + if (trackMatch) { + return { type: "track", id: trackMatch[1] }; + } + + return null; + } + + /** + * Fetch a playlist by ID + */ + async getPlaylist(playlistId: string): Promise { + try { + console.log(`Deezer: Fetching playlist ${playlistId}...`); + + const response = await axios.get(`${DEEZER_API}/playlist/${playlistId}`, { + timeout: 15000, + }); + + const data = response.data; + if (data.error) { + console.error("Deezer API error:", data.error); + return null; + } + + const tracks: DeezerTrack[] = (data.tracks?.data || []).map((track: any) => ({ + deezerId: String(track.id), + title: track.title || "Unknown", + artist: track.artist?.name || "Unknown Artist", + artistId: String(track.artist?.id || ""), + album: track.album?.title || "Unknown Album", + albumId: String(track.album?.id || ""), + durationMs: (track.duration || 0) * 1000, + previewUrl: track.preview || null, + coverUrl: track.album?.cover_medium || track.album?.cover || null, + })); + + console.log(`Deezer: Fetched playlist "${data.title}" with ${tracks.length} tracks`); + + return { + id: String(data.id), + title: data.title || "Unknown Playlist", + description: data.description || null, + creator: data.creator?.name || "Unknown", + imageUrl: data.picture_medium || data.picture || null, + trackCount: data.nb_tracks || tracks.length, + tracks, + isPublic: data.public ?? true, + }; + } catch (error: any) { + console.error("Deezer playlist fetch error:", error.message); + return null; + } + } + + /** + * Get chart playlists (top playlists) + */ + async getChartPlaylists(limit: number = 20): Promise { + try { + const response = await axios.get(`${DEEZER_API}/chart/0/playlists`, { + params: { limit }, + timeout: 10000, + }); + + return (response.data?.data || []).map((playlist: any) => ({ + id: String(playlist.id), + title: playlist.title || "Unknown", + description: null, + creator: playlist.user?.name || "Deezer", + imageUrl: playlist.picture_medium || playlist.picture || null, + trackCount: playlist.nb_tracks || 0, + fans: playlist.fans || 0, + })); + } catch (error: any) { + console.error("Deezer chart playlists error:", error.message); + return []; + } + } + + /** + * Search for playlists + */ + async searchPlaylists(query: string, limit: number = 20): Promise { + try { + const response = await axios.get(`${DEEZER_API}/search/playlist`, { + params: { q: query, limit }, + timeout: 10000, + }); + + return (response.data?.data || []).map((playlist: any) => ({ + id: String(playlist.id), + title: playlist.title || "Unknown", + description: null, + creator: playlist.user?.name || "Unknown", + imageUrl: playlist.picture_medium || playlist.picture || null, + trackCount: playlist.nb_tracks || 0, + fans: 0, + })); + } catch (error: any) { + console.error("Deezer playlist search error:", error.message); + return []; + } + } + + /** + * Get featured/curated playlists from multiple sources + * Combines chart playlists with popular genre-based searches + * Cached for 24 hours + */ + async getFeaturedPlaylists(limit: number = 50): Promise { + const cacheKey = `playlists:featured:${limit}`; + const cached = await this.getCached(cacheKey); + if (cached) { + console.log("Deezer: Returning cached featured playlists"); + return JSON.parse(cached); + } + + try { + const allPlaylists: DeezerPlaylistPreview[] = []; + const seenIds = new Set(); + + // 1. Get chart playlists (max 99 available) + console.log("Deezer: Fetching chart playlists from API..."); + const chartPlaylists = await this.getChartPlaylists(Math.min(limit, 99)); + for (const p of chartPlaylists) { + if (!seenIds.has(p.id)) { + seenIds.add(p.id); + allPlaylists.push(p); + } + } + console.log(`Deezer: Got ${chartPlaylists.length} chart playlists`); + + // 2. If we need more, search for popular genre playlists + if (allPlaylists.length < limit) { + const genres = ["pop", "rock", "hip hop", "electronic", "r&b", "indie", "jazz", "classical", "metal", "country"]; + + for (const genre of genres) { + if (allPlaylists.length >= limit) break; + + try { + const genrePlaylists = await this.searchPlaylists(genre, 10); + for (const p of genrePlaylists) { + if (!seenIds.has(p.id) && allPlaylists.length < limit) { + seenIds.add(p.id); + allPlaylists.push(p); + } + } + } catch (e) { + // Continue with other genres + } + } + } + + const result = allPlaylists.slice(0, limit); + console.log(`Deezer: Caching ${result.length} featured playlists`); + await this.setCache(cacheKey, JSON.stringify(result)); + return result; + } catch (error: any) { + console.error("Deezer featured playlists error:", error.message); + return []; + } + } + + /** + * Get genres/categories available on Deezer + */ + /** + * Get genres/categories available on Deezer + * Cached for 24 hours + */ + async getGenres(): Promise> { + const cacheKey = "genres:all"; + const cached = await this.getCached(cacheKey); + if (cached) { + console.log("Deezer: Returning cached genres"); + return JSON.parse(cached); + } + + try { + console.log("Deezer: Fetching genres from API..."); + const response = await axios.get(`${DEEZER_API}/genre`, { + timeout: 10000, + }); + + const genres = (response.data?.data || []) + .filter((g: any) => g.id !== 0) // Skip "All" genre + .map((genre: any) => ({ + id: genre.id, + name: genre.name, + imageUrl: genre.picture_medium || genre.picture || null, + })); + + console.log(`Deezer: Caching ${genres.length} genres`); + await this.setCache(cacheKey, JSON.stringify(genres)); + return genres; + } catch (error: any) { + console.error("Deezer genres error:", error.message); + return []; + } + } + + /** + * Get playlists for a specific genre by searching + */ + async getGenrePlaylists(genreName: string, limit: number = 20): Promise { + return this.searchPlaylists(genreName, limit); + } + + // ============================================ + // Radio Methods + // ============================================ + + /** + * Get all radio stations (mood/theme based mixes) + * Cached for 24 hours + */ + async getRadioStations(): Promise { + const cacheKey = "radio:stations"; + const cached = await this.getCached(cacheKey); + if (cached) { + console.log("Deezer: Returning cached radio stations"); + return JSON.parse(cached); + } + + try { + console.log("Deezer: Fetching radio stations from API..."); + const response = await axios.get(`${DEEZER_API}/radio`, { + timeout: 10000, + }); + + const stations = (response.data?.data || []).map((radio: any) => ({ + id: String(radio.id), + title: radio.title || "Unknown", + description: null, + imageUrl: radio.picture_medium || radio.picture || null, + type: "radio" as const, + })); + + console.log(`Deezer: Got ${stations.length} radio stations, caching...`); + await this.setCache(cacheKey, JSON.stringify(stations)); + return stations; + } catch (error: any) { + console.error("Deezer radio stations error:", error.message); + return []; + } + } + + /** + * Get radio stations organized by genre + */ + /** + * Get radio stations organized by genre + * Cached for 24 hours + */ + async getRadiosByGenre(): Promise { + const cacheKey = "radio:by-genre"; + const cached = await this.getCached(cacheKey); + if (cached) { + console.log("Deezer: Returning cached radios by genre"); + return JSON.parse(cached); + } + + try { + console.log("Deezer: Fetching radios by genre from API..."); + const response = await axios.get(`${DEEZER_API}/radio/genres`, { + timeout: 10000, + }); + + const genres = (response.data?.data || []).map((genre: any) => ({ + id: genre.id, + name: genre.title || "Unknown", + radios: (genre.radios || []).map((radio: any) => ({ + id: String(radio.id), + title: radio.title || "Unknown", + description: null, + imageUrl: radio.picture_medium || radio.picture || null, + type: "radio" as const, + })), + })); + + console.log(`Deezer: Got ${genres.length} genre categories with radios, caching...`); + await this.setCache(cacheKey, JSON.stringify(genres)); + return genres; + } catch (error: any) { + console.error("Deezer radios by genre error:", error.message); + return []; + } + } + + /** + * Get tracks from a radio station (returns as DeezerPlaylist for consistency) + */ + async getRadioTracks(radioId: string): Promise { + try { + console.log(`Deezer: Fetching radio ${radioId} tracks...`); + + // First get radio info + const infoResponse = await axios.get(`${DEEZER_API}/radio/${radioId}`, { + timeout: 10000, + }); + const radioInfo = infoResponse.data; + + // Then get tracks + const tracksResponse = await axios.get(`${DEEZER_API}/radio/${radioId}/tracks`, { + params: { limit: 100 }, + timeout: 15000, + }); + + const tracks: DeezerTrack[] = (tracksResponse.data?.data || []).map((track: any) => ({ + deezerId: String(track.id), + title: track.title || "Unknown", + artist: track.artist?.name || "Unknown Artist", + artistId: String(track.artist?.id || ""), + album: track.album?.title || "Unknown Album", + albumId: String(track.album?.id || ""), + durationMs: (track.duration || 0) * 1000, + previewUrl: track.preview || null, + coverUrl: track.album?.cover_medium || track.album?.cover || null, + })); + + console.log(`Deezer: Fetched radio "${radioInfo.title}" with ${tracks.length} tracks`); + + return { + id: `radio-${radioId}`, + title: radioInfo.title || "Radio Station", + description: `Deezer Radio - ${radioInfo.title}`, + creator: "Deezer", + imageUrl: radioInfo.picture_medium || radioInfo.picture || null, + trackCount: tracks.length, + tracks, + isPublic: true, + }; + } catch (error: any) { + console.error("Deezer radio tracks error:", error.message); + return null; + } + } + + /** + * Get editorial/curated content for a specific genre + * Returns releases and playlists for that genre + */ + async getEditorialContent(genreId: number): Promise<{ + playlists: DeezerPlaylistPreview[]; + radios: DeezerRadioStation[]; + }> { + try { + // Get genre-specific playlists via search + const genreResponse = await axios.get(`${DEEZER_API}/genre/${genreId}`, { + timeout: 10000, + }); + const genreName = genreResponse.data?.name || ""; + + // Search for playlists with this genre + const playlists = genreName ? await this.searchPlaylists(genreName, 20) : []; + + // Get radios for this genre from the genres endpoint + const radiosResponse = await axios.get(`${DEEZER_API}/radio/genres`, { + timeout: 10000, + }); + + const genreRadios = (radiosResponse.data?.data || []).find((g: any) => g.id === genreId); + const radios: DeezerRadioStation[] = (genreRadios?.radios || []).map((radio: any) => ({ + id: String(radio.id), + title: radio.title || "Unknown", + description: null, + imageUrl: radio.picture_medium || radio.picture || null, + type: "radio" as const, + })); + + return { playlists, radios }; + } catch (error: any) { + console.error("Deezer editorial content error:", error.message); + return { playlists: [], radios: [] }; + } + } +} + +export const deezerService = new DeezerService(); diff --git a/backend/src/services/discoverWeekly.ts b/backend/src/services/discoverWeekly.ts new file mode 100644 index 0000000..ff3610e --- /dev/null +++ b/backend/src/services/discoverWeekly.ts @@ -0,0 +1,3122 @@ +/** + * Discovery Weekly Service (Refactored) + * + * Generates weekly discovery playlists using Last.fm recommendations, + * downloads via Lidarr, and only shows songs after successful import. + * + * Key improvements: + * - Prisma transactions for atomic operations + * - Pre-fetched and cached recommendations + * - Structured logging with batch logs field + * - No dynamic imports + */ + +import { Prisma } from "@prisma/client"; +import { prisma } from "../utils/db"; +import { lastFmService } from "./lastfm"; +import { musicBrainzService } from "./musicbrainz"; +import { simpleDownloadManager } from "./simpleDownloadManager"; +import { lidarrService } from "./lidarr"; +import { scanQueue } from "../workers/queues"; +import { startOfWeek, subWeeks } from "date-fns"; +import { getSystemSettings } from "../utils/systemSettings"; +import { discoveryLogger } from "./discoveryLogger"; + +interface SeedArtist { + name: string; + mbid?: string; +} + +interface RecommendedAlbum { + artistName: string; + artistMbid?: string; + albumTitle: string; + albumMbid: string; + similarity: number; + tier?: "high" | "medium" | "explore" | "wildcard"; +} + +// Tier distribution for variety in recommendations +// This ensures each playlist has a mix of similarity levels +const TIER_DISTRIBUTION = { + high: 0.3, // 30% from very similar artists (>80% match) + medium: 0.4, // 40% from moderately similar (50-80% match) + explore: 0.2, // 20% from stretch picks (30-50% match) + wildcard: 0.1, // 10% from genre tags (variety) +}; + +interface BatchLogEntry { + timestamp: string; + level: "info" | "warn" | "error"; + message: string; +} + +/** + * Calculate tier from Last.fm similarity score + * Last.fm typically returns scores in 0.5-0.9 range for similar artists + * Adjusted thresholds for better distribution: + * - High Match: 70-100% (0.7-1.0) + * - Medium Match: 50-69% (0.5-0.69) + * - Explore: 30-49% (0.3-0.49) + * - Wild Card: 0-29% (0-0.29) or explicitly set + */ +function getTierFromSimilarity( + similarity: number +): "high" | "medium" | "explore" | "wildcard" { + if (similarity >= 0.7) return "high"; + if (similarity >= 0.5) return "medium"; + if (similarity >= 0.3) return "explore"; + return "wildcard"; +} + +export class DiscoverWeeklyService { + /** + * Process liked albums before generating new playlist + * - Moves LIKED albums to LIBRARY + * - Deletes non-liked (ACTIVE) albums + * - Cleans up Lidarr + */ + private async processLikedAlbumsBeforeGeneration( + userId: string, + settings: any + ): Promise { + console.log(`\n Processing previous discovery albums...`); + + // Find all active discovery albums for this user + const discoveryAlbums = await prisma.discoveryAlbum.findMany({ + where: { + userId, + status: { in: ["ACTIVE", "LIKED"] }, + }, + }); + + if (discoveryAlbums.length === 0) { + console.log(` No previous discovery albums to process`); + return; + } + + const likedAlbums = discoveryAlbums.filter((a) => a.status === "LIKED"); + const activeAlbums = discoveryAlbums.filter( + (a) => a.status === "ACTIVE" + ); + + console.log(` Found ${likedAlbums.length} liked albums to keep`); + console.log( + ` Found ${activeAlbums.length} non-liked albums to remove` + ); + + // Process liked albums - move to library + for (const album of likedAlbums) { + try { + // Find the album in database + const dbAlbum = await prisma.album.findFirst({ + where: { rgMbid: album.rgMbid }, + include: { artist: true }, + }); + + if (dbAlbum) { + // Update album location to LIBRARY + await prisma.album.update({ + where: { id: dbAlbum.id }, + data: { location: "LIBRARY" }, + }); + + // Create OwnedAlbum record + await prisma.ownedAlbum.upsert({ + where: { + artistId_rgMbid: { + artistId: dbAlbum.artistId, + rgMbid: dbAlbum.rgMbid, + }, + }, + create: { + artistId: dbAlbum.artistId, + rgMbid: dbAlbum.rgMbid, + source: "discover_liked", + }, + update: {}, + }); + + console.log( + ` Moved to library: ${album.artistName} - ${album.albumTitle}` + ); + } + + // Mark as MOVED + 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 albums - delete them + for (const album of activeAlbums) { + try { + // Delete from Lidarr if enabled + if ( + settings.lidarrEnabled && + settings.lidarrUrl && + settings.lidarrApiKey && + album.lidarrAlbumId + ) { + try { + const axios = (await import("axios")).default; + await axios.delete( + `${settings.lidarrUrl}/api/v1/album/${album.lidarrAlbumId}`, + { + params: { deleteFiles: true }, + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + } catch (lidarrError: any) { + if (lidarrError.response?.status !== 404) { + console.log( + ` Lidarr delete failed: ${lidarrError.message}` + ); + } + } + } + + // Delete from database + const dbAlbum = await prisma.album.findFirst({ + where: { rgMbid: album.rgMbid }, + }); + + if (dbAlbum) { + await prisma.track.deleteMany({ + where: { albumId: dbAlbum.id }, + }); + await prisma.album.delete({ where: { id: dbAlbum.id } }); + } + + // Delete discovery track records + await prisma.discoveryTrack.deleteMany({ + where: { discoveryAlbumId: album.id }, + }); + + // Mark as DELETED + await prisma.discoveryAlbum.update({ + where: { id: album.id }, + data: { status: "DELETED" }, + }); + + console.log( + ` Deleted: ${album.artistName} - ${album.albumTitle}` + ); + } catch (error: any) { + console.error( + ` ✗ Failed to delete ${album.albumTitle}: ${error.message}` + ); + } + } + + // Clean up unavailable albums from previous week + await prisma.unavailableAlbum.deleteMany({ where: { userId } }); + + console.log(` Previous discovery cleanup complete`); + } + + /** + * Add a log entry to batch logs + */ + private async addBatchLog( + batchId: string, + level: "info" | "warn" | "error", + message: string + ): Promise { + try { + const batch = await prisma.discoveryBatch.findUnique({ + where: { id: batchId }, + select: { logs: true }, + }); + + const logs = (batch?.logs as unknown as BatchLogEntry[]) || []; + logs.push({ + timestamp: new Date().toISOString(), + level, + message, + }); + + // Keep only last 100 log entries + const trimmedLogs = logs.slice(-100); + + await prisma.discoveryBatch.update({ + where: { id: batchId }, + data: { logs: trimmedLogs as any }, + }); + } catch (error) { + // Don't fail if logging fails + console.error("Failed to add batch log:", error); + } + } + + /** + * Main entry: Generate Discovery Weekly + */ + async generatePlaylist(userId: string, jobId?: number) { + // Start a dedicated log file for this generation + const logPath = discoveryLogger.start(userId, jobId); + discoveryLogger.info(`Log file: ${logPath}`); + + try { + // Check if Lidarr is enabled and configured + discoveryLogger.section("CONFIGURATION CHECK"); + const settings = await getSystemSettings(); + if ( + !settings?.lidarrEnabled || + !settings?.lidarrUrl || + !settings?.lidarrApiKey + ) { + discoveryLogger.error("Lidarr must be enabled and configured"); + discoveryLogger.end(false, "Lidarr not configured"); + throw new Error( + "Lidarr must be enabled and configured to use Discovery Weekly" + ); + } + discoveryLogger.success("Lidarr configured"); + discoveryLogger.table({ + "Lidarr URL": settings.lidarrUrl, + "API Key": settings.lidarrApiKey + ? "***" + settings.lidarrApiKey.slice(-4) + : "not set", + }); + + const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + + // Get user config + const config = await prisma.userDiscoverConfig.findUnique({ + where: { userId }, + }); + + if (!config || !config.enabled) { + discoveryLogger.error("Discovery Weekly not enabled for user"); + discoveryLogger.end(false, "Not enabled"); + throw new Error("Discovery Weekly not enabled"); + } + + // Get download ratio from config (default 1.3) + const downloadRatio = config.downloadRatio ?? 1.3; + + discoveryLogger.table({ + "Target Songs": config.playlistSize, + "Download Ratio": `${downloadRatio}x`, + "Week Start": weekStart.toISOString().split("T")[0], + }); + + // CRITICAL: Process previous week's liked albums before generating new ones + discoveryLogger.section("PROCESSING PREVIOUS WEEK"); + await this.processLikedAlbumsBeforeGeneration(userId, settings); + + const targetCount = config.playlistSize; + + // Step 1: Get seed artists + discoveryLogger.section("STEP 1: SEED ARTISTS"); + const seeds = await this.getSeedArtists(userId); + if (seeds.length === 0) { + discoveryLogger.error( + "No seed artists found - need listening history" + ); + discoveryLogger.end(false, "No seed artists"); + throw new Error( + "No seed artists found - need listening history" + ); + } + discoveryLogger.success(`Found ${seeds.length} seed artists:`); + discoveryLogger.list( + seeds.map( + (s) => `${s.name}${s.mbid ? ` (${s.mbid})` : " (no MBID)"}` + ) + ); + + // Step 2: Pre-fetch and cache similar artists (parallel with rate limiting) + discoveryLogger.section("STEP 2: SIMILAR ARTISTS"); + const similarArtistsMap = await this.prefetchSimilarArtists(seeds); + discoveryLogger.success( + `Cached ${similarArtistsMap.size} similar artist sets` + ); + for (const [key, similar] of similarArtistsMap.entries()) { + const seedName = + seeds.find((s) => s.mbid === key || s.name === key)?.name || + key; + discoveryLogger.write( + ` ${seedName}: ${similar.length} similar artists`, + 1 + ); + } + + // Step 3: Find recommended albums using multi-strategy discovery + // REQUEST MORE ALBUMS than target to account for download failures + // User configurable ratio (default 1.3x) to control bandwidth usage + const albumsToRequest = Math.ceil(targetCount * downloadRatio); + + discoveryLogger.section( + "STEP 3: ALBUM RECOMMENDATIONS (Multi-Strategy)" + ); + discoveryLogger.info( + `Requesting ${albumsToRequest} albums (${downloadRatio}x target of ${targetCount}) to account for failures` + ); + + const recommended = await this.findRecommendedAlbumsMultiStrategy( + seeds, + similarArtistsMap, + albumsToRequest, // Request more albums! + userId + ); + + if (recommended.length === 0) { + discoveryLogger.error( + "No recommendations found after filtering" + ); + discoveryLogger.end(false, "No recommendations found"); + throw new Error("No recommendations found"); + } + + // MINIMUM THRESHOLD CHECK: Ensure we have enough candidates + // We need at least targetCount albums, ideally more for variety + const minRecommendations = targetCount; + if (recommended.length < minRecommendations) { + discoveryLogger.warn( + `Only ${recommended.length} recommendations found, need at least ${minRecommendations} for ${targetCount} unique albums` + ); + discoveryLogger.warn( + "Consider expanding seed artists or playing more music" + ); + await this.addBatchLog( + "threshold-check", + "warn", + `Low recommendations: ${recommended.length}/${minRecommendations} minimum (target: ${targetCount} unique albums)` + ); + } + + discoveryLogger.success( + `${recommended.length} albums recommended for download` + ); + discoveryLogger.list( + recommended.map( + (r) => + `${r.artistName} - ${r.albumTitle} (similarity: ${( + r.similarity * 100 + ).toFixed(0)}%)` + ) + ); + + // Step 4: Create batch and jobs in a transaction + discoveryLogger.section("STEP 4: CREATE BATCH & JOBS"); + const batch = await prisma.$transaction(async (tx) => { + // Create discovery batch + const newBatch = await tx.discoveryBatch.create({ + data: { + userId, + weekStart, + targetSongCount: targetCount, + status: "downloading", + totalAlbums: recommended.length, + completedAlbums: 0, + failedAlbums: 0, + logs: [ + { + timestamp: new Date().toISOString(), + level: "info", + message: `Started with ${recommended.length} albums to download`, + }, + ] as any, + }, + }); + discoveryLogger.success(`Created batch: ${newBatch.id}`); + + // Create all download jobs in the same transaction + for (const album of recommended) { + // Ensure similarity is a valid number + const similarity = + typeof album.similarity === "number" && + !isNaN(album.similarity) + ? album.similarity + : 0.5; + + // Check for existing pending/processing job to avoid duplicates + const existingJob = await tx.downloadJob.findFirst({ + where: { + targetMbid: album.albumMbid, + status: { in: ["pending", "processing"] }, + }, + }); + + if (existingJob) { + console.log( + ` Skipping job: ${album.artistName} - ${album.albumTitle} (already in queue: ${existingJob.id})` + ); + continue; + } + + console.log( + ` Creating job: ${album.artistName} - ${album.albumTitle} (similarity: ${similarity}, tier: ${album.tier})` + ); + + await tx.downloadJob.create({ + data: { + userId, + subject: `${album.artistName} - ${album.albumTitle}`, + type: "album", + targetMbid: album.albumMbid, + status: "pending", + discoveryBatchId: newBatch.id, + metadata: { + downloadType: "discovery", + rootFolderPath: "/music", + artistName: album.artistName, + artistMbid: album.artistMbid, + albumTitle: album.albumTitle, + albumMbid: album.albumMbid, + similarity: similarity, + tier: album.tier, + }, + }, + }); + } + + return newBatch; + }); + discoveryLogger.success( + `Created ${recommended.length} download jobs` + ); + + // Step 5: Start downloads outside transaction (they involve external APIs) + discoveryLogger.section("STEP 5: START DOWNLOADS"); + let downloadsStarted = 0; + let downloadsFailed = 0; + + const jobs = await prisma.downloadJob.findMany({ + where: { discoveryBatchId: batch.id }, + }); + + for (const job of jobs) { + const metadata = job.metadata as any; + try { + const result = await simpleDownloadManager.startDownload( + job.id, + metadata.artistName, + metadata.albumTitle, + metadata.albumMbid, + userId, + true // isDiscovery - tag artist in Lidarr for cleanup + ); + + if (result.success) { + downloadsStarted++; + discoveryLogger.success( + `Started: ${metadata.artistName} - ${metadata.albumTitle}`, + 1 + ); + } else { + downloadsFailed++; + discoveryLogger.error( + `Failed: ${metadata.albumTitle} - ${result.error}`, + 1 + ); + await this.addBatchLog( + batch.id, + "error", + `Failed to start: ${metadata.albumTitle} - ${result.error}` + ); + } + } catch (error: any) { + downloadsFailed++; + discoveryLogger.error( + `Error: ${metadata.albumTitle}: ${error.message}`, + 1 + ); + await this.addBatchLog( + batch.id, + "error", + `Error starting: ${metadata.albumTitle} - ${error.message}` + ); + } + } + + discoveryLogger.section("GENERATION COMPLETE"); + discoveryLogger.table({ + "Downloads Started": downloadsStarted, + "Downloads Failed": downloadsFailed, + "Total Albums": recommended.length, + "Batch ID": batch.id, + }); + + await this.addBatchLog( + batch.id, + "info", + `${downloadsStarted} downloads started, waiting for webhooks` + ); + + discoveryLogger.end( + true, + `${downloadsStarted}/${recommended.length} downloads queued` + ); + + return { + success: true, + playlistName: `Discover Weekly (Week of ${weekStart.toLocaleDateString()})`, + songCount: 0, + batchId: batch.id, + }; + } catch (error: any) { + discoveryLogger.error(`Generation failed: ${error.message}`); + discoveryLogger.end(false, error.message); + throw error; + } + } + + /** + * Pre-fetch similar artists for all seeds (parallel with rate limiting) + * Now includes exponential backoff retry for API failures + */ + private async prefetchSimilarArtists( + seeds: SeedArtist[] + ): Promise> { + const cache = new Map(); + + // Helper: fetch with exponential backoff retry + const fetchWithRetry = async ( + seed: SeedArtist, + maxRetries = 3 + ): Promise => { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const similar = await lastFmService.getSimilarArtists( + seed.mbid || "", + seed.name, + 20 + ); + return similar; + } catch (error: any) { + const isRetryable = + error.response?.status === 429 || + error.response?.status >= 500 || + error.code === "ECONNRESET" || + error.code === "ETIMEDOUT"; + + if (isRetryable && attempt < maxRetries) { + const delay = Math.pow(2, attempt) * 500; // 1s, 2s, 4s + console.warn( + ` Retry ${attempt}/${maxRetries} for ${seed.name} in ${delay}ms (${error.message})` + ); + await new Promise((r) => setTimeout(r, delay)); + continue; + } + + console.warn( + ` Failed to get similar artists for ${seed.name}: ${error.message}` + ); + return []; + } + } + return []; + }; + + // Process seeds in smaller batches to avoid overwhelming APIs + const batchSize = 3; + for (let i = 0; i < seeds.length; i += batchSize) { + const seedBatch = seeds.slice(i, i + batchSize); + + const results = await Promise.all( + seedBatch.map(async (seed) => { + const similar = await fetchWithRetry(seed); + return { key: seed.mbid || seed.name, similar }; + }) + ); + + for (const { key, similar } of results) { + cache.set(key, similar); + } + + // Small delay between batches + if (i + batchSize < seeds.length) { + await new Promise((r) => setTimeout(r, 300)); + } + } + + return cache; + } + + /** + * Check for batches stuck in "downloading" or "scanning" status for too long + * Called periodically from queue cleaner + */ + async checkStuckBatches(): Promise { + const BATCH_TIMEOUT_WITH_COMPLETIONS = 30 * 60 * 1000; // 30 minutes + const BATCH_TIMEOUT_NO_COMPLETIONS = 60 * 60 * 1000; // 60 minutes + const ABSOLUTE_MAX_TIMEOUT = 2 * 60 * 60 * 1000; // 2 hours - force fail any batch older than this + + const stuckBatches = await prisma.discoveryBatch.findMany({ + where: { + status: { in: ["downloading", "scanning"] }, + }, + include: { jobs: true }, + }); + + let forcedCount = 0; + + for (const batch of stuckBatches) { + const batchAge = Date.now() - batch.createdAt.getTime(); + const completedJobs = batch.jobs.filter( + (j) => j.status === "completed" + ); + const pendingJobs = batch.jobs.filter( + (j) => j.status === "pending" || j.status === "processing" + ); + + // Absolute timeout - fail any batch older than 2 hours regardless of state + if (batchAge > ABSOLUTE_MAX_TIMEOUT) { + console.log( + `\n⏰ [BATCH FORCE FAIL] Batch ${batch.id} is ${Math.round( + batchAge / 3600000 + )}h old - force failing` + ); + + await prisma.discoveryBatch.update({ + where: { id: batch.id }, + data: { + status: "failed", + errorMessage: "Batch timed out after 2 hours", + completedAt: new Date(), + }, + }); + + // Mark any remaining pending/processing jobs as failed + await prisma.downloadJob.updateMany({ + where: { + discoveryBatchId: batch.id, + status: { in: ["pending", "processing"] }, + }, + data: { + status: "failed", + error: "Batch force-failed due to timeout", + completedAt: new Date(), + }, + }); + + forcedCount++; + continue; + } + + // Check if batch should be force-completed + const hasCompletions = completedJobs.length > 0; + const timeout = hasCompletions + ? BATCH_TIMEOUT_WITH_COMPLETIONS + : BATCH_TIMEOUT_NO_COMPLETIONS; + + if (batchAge > timeout && pendingJobs.length > 0) { + console.log( + `\n⏰ [BATCH TIMEOUT] Batch ${ + batch.id + } stuck for ${Math.round(batchAge / 60000)}min` + ); + console.log( + ` Completed: ${completedJobs.length}, Pending: ${pendingJobs.length}` + ); + + // Mark all pending jobs as failed (timed out) + await prisma.downloadJob.updateMany({ + where: { + discoveryBatchId: batch.id, + status: { in: ["pending", "processing"] }, + }, + data: { + status: "failed", + error: "Batch timeout - download took too long", + completedAt: new Date(), + }, + }); + + console.log( + ` Marked ${pendingJobs.length} pending jobs as failed` + ); + + // Now trigger batch completion check + await this.checkBatchCompletion(batch.id); + forcedCount++; + } + } + + return forcedCount; + } + + /** + * Check if discovery batch is complete and trigger final steps + */ + async checkBatchCompletion(batchId: string) { + console.log(`\n[BATCH ${batchId}] Checking completion...`); + + const batch = await prisma.discoveryBatch.findUnique({ + where: { id: batchId }, + include: { jobs: true }, + }); + + if (!batch) { + console.log(`[BATCH ${batchId}] Not found - skipping`); + return; + } + + // Skip if already completed/failed/scanning + if ( + batch.status === "completed" || + batch.status === "failed" || + batch.status === "scanning" + ) { + console.log( + `[BATCH ${batchId}] Already ${batch.status} - skipping` + ); + return; + } + + const completedJobs = batch.jobs.filter( + (j) => j.status === "completed" + ); + const failedJobs = batch.jobs.filter( + (j) => j.status === "failed" || j.status === "exhausted" + ); + const pendingJobs = batch.jobs.filter( + (j) => j.status === "pending" || j.status === "processing" + ); + + const completed = completedJobs.length; + const failed = failedJobs.length; + const total = batch.jobs.length; + + console.log( + `[BATCH ${batchId}] Status: ${completed} completed, ${failed} failed, ${pendingJobs.length} pending (total: ${total})` + ); + + // Wait for ALL downloads to complete/fail + if (pendingJobs.length > 0) { + console.log( + `[BATCH ${batchId}] Still waiting for ${pendingJobs.length} downloads` + ); + return; + } + + console.log( + `[BATCH ${batchId}] All jobs done! Transitioning to scan phase...` + ); + + // All jobs finished - use transaction to update batch and create unavailable records + await prisma.$transaction(async (tx) => { + // Create UnavailableAlbum records for failed downloads + for (const job of failedJobs) { + const metadata = job.metadata as any; + try { + await tx.unavailableAlbum.upsert({ + where: { + userId_weekStartDate_albumMbid: { + userId: batch.userId, + weekStartDate: batch.weekStart, + albumMbid: job.targetMbid, + }, + }, + create: { + userId: batch.userId, + albumMbid: job.targetMbid, + artistName: metadata?.artistName || "Unknown", + albumTitle: metadata?.albumTitle || "Unknown", + similarity: metadata?.similarity || 0.5, + tier: + metadata?.tier || + getTierFromSimilarity( + metadata?.similarity || 0.5 + ), + attemptNumber: 1, + weekStartDate: batch.weekStart, + }, + update: { + attemptNumber: { increment: 1 }, + }, + }); + } catch (e) { + // Ignore duplicate errors + } + } + + // Update batch status + if (completed === 0) { + await tx.discoveryBatch.update({ + where: { id: batchId }, + data: { + status: "failed", + completedAlbums: 0, + failedAlbums: failed, + errorMessage: "All downloads failed", + completedAt: new Date(), + }, + }); + } else { + await tx.discoveryBatch.update({ + where: { id: batchId }, + data: { + status: "scanning", + completedAlbums: completed, + failedAlbums: failed, + }, + }); + } + }); + + if (completed === 0) { + console.log(` All downloads failed`); + await this.addBatchLog(batchId, "error", "All downloads failed"); + + // Cleanup failed artists from Lidarr + await this.cleanupFailedArtists(batchId); + return; + } + + // All successful downloads will be included in the playlist + console.log( + ` ${completed} albums ready for playlist. Triggering scan...` + ); + await this.addBatchLog( + batchId, + "info", + `${completed} completed, ${failed} failed. All successful downloads will be in playlist.` + ); + + // Trigger ONE scan with batch ID + await scanQueue.add("scan", { + type: "full", + source: "discover-weekly-completion", + discoveryBatchId: batchId, + }); + + console.log( + ` Scan queued - will build playlist after scan completes` + ); + } + + /** + * Build final playlist after scan completes (atomic transaction) + */ + async buildFinalPlaylist(batchId: string) { + console.log(`\n Building final playlist for batch ${batchId}...`); + + const batch = await prisma.discoveryBatch.findUnique({ + where: { id: batchId }, + }); + + if (!batch) { + console.log(` Batch not found`); + return; + } + + // Get completed download jobs + const completedJobs = await prisma.downloadJob.findMany({ + where: { + discoveryBatchId: batchId, + status: "completed", + }, + }); + + console.log(` Found ${completedJobs.length} completed downloads`); + await this.addBatchLog( + batchId, + "info", + `Building playlist from ${completedJobs.length} completed downloads` + ); + + // Build search criteria from completed jobs - use MBID (primary) + artist/album name (fallback) + const searchCriteria = completedJobs + .map((j) => { + const metadata = j.metadata as any; + return { + artistName: metadata?.artistName || "", + albumTitle: metadata?.albumTitle || "", + albumMbid: metadata?.albumMbid || j.targetMbid || "", + }; + }) + .filter((c) => c.artistName && c.albumTitle); + + console.log( + ` Searching for tracks using MBID (primary) + name fallback:` + ); + for (const c of searchCriteria) { + console.log( + ` - "${c.albumTitle}" by "${c.artistName}" (MBID: ${ + c.albumMbid || "none" + })` + ); + } + + // Find tracks - try MBID first (most accurate), then fall back to name matching + let allTracks: any[] = []; + for (const criteria of searchCriteria) { + let tracks: any[] = []; + + // PRIMARY: Search by rgMbid (most accurate) + if (criteria.albumMbid) { + tracks = await prisma.track.findMany({ + where: { + album: { rgMbid: criteria.albumMbid }, + }, + include: { + album: { include: { artist: true } }, + }, + }); + if (tracks.length > 0) { + console.log( + ` [MBID] Found ${tracks.length} tracks for "${criteria.albumTitle}"` + ); + } + } + + // FALLBACK: Search by artist name + album title (case-insensitive) + if (tracks.length === 0) { + tracks = await prisma.track.findMany({ + where: { + album: { + title: { + equals: criteria.albumTitle, + mode: "insensitive", + }, + artist: { + name: { + equals: criteria.artistName, + mode: "insensitive", + }, + }, + }, + }, + include: { + album: { include: { artist: true } }, + }, + }); + if (tracks.length > 0) { + console.log( + ` [NAME] Found ${tracks.length} tracks for "${criteria.albumTitle}"` + ); + } + } + + // FALLBACK 2: Normalized name search (handles Unicode/special chars) + if (tracks.length === 0) { + // Normalize for comparison + const normalizeStr = (s: string) => + s + .toLowerCase() + .normalize("NFKD") // Decompose Unicode + .replace(/[\u0300-\u036f]/g, "") // Remove diacritics + .replace(/[^\w\s]/g, " ") // Replace punctuation with space + .replace(/\s+/g, " ") // Normalize whitespace + .trim(); + + const normalizedAlbum = normalizeStr(criteria.albumTitle); + const normalizedArtist = normalizeStr(criteria.artistName); + + // Get all albums from this artist (by normalized name) + const artistAlbums = await prisma.album.findMany({ + where: { + artist: { + name: { + mode: "insensitive", + contains: normalizedArtist.split(" ")[0], + }, + }, + }, + include: { artist: true, tracks: true }, + }); + + // Find matching album by normalized title + for (const album of artistAlbums) { + if ( + normalizeStr(album.title) === normalizedAlbum || + normalizeStr(album.title).includes(normalizedAlbum) || + normalizedAlbum.includes(normalizeStr(album.title)) + ) { + tracks = album.tracks.map((t: any) => ({ + ...t, + album: { ...album, artist: album.artist }, + })); + if (tracks.length > 0) { + console.log( + ` [NORMALIZED] Found ${tracks.length} tracks for "${criteria.albumTitle}"` + ); + break; + } + } + } + } + + if (tracks.length === 0) { + console.log( + ` [MISS] No tracks found for "${criteria.albumTitle}" by "${criteria.artistName}"` + ); + } + + allTracks.push(...tracks); + } + + // Remove duplicates (same track ID) + const uniqueTracks = Array.from( + new Map(allTracks.map((t) => [t.id, t])).values() + ); + allTracks = uniqueTracks; + + console.log(` Found ${allTracks.length} tracks from imported albums`); + + if (allTracks.length === 0) { + console.log( + ` No tracks found after scan - albums may not have imported yet` + ); + await prisma.discoveryBatch.update({ + where: { id: batchId }, + data: { + status: "failed", + errorMessage: "No tracks found after scan", + completedAt: new Date(), + }, + }); + await this.addBatchLog( + batchId, + "error", + "No tracks found after scan" + ); + return; + } + + // ============================================== + // PLAYLIST COMPOSITION: ALL Discovery + ~20% Anchors + // ONE TRACK PER ALBUM - Each album contributes only 1 track + // Include ALL successfully downloaded albums! + // ============================================== + + // Group tracks by album ID and pick ONE random track per album + const tracksByAlbum = new Map(); + for (const track of allTracks) { + const albumId = track.album.id; + if (!tracksByAlbum.has(albumId)) { + tracksByAlbum.set(albumId, []); + } + tracksByAlbum.get(albumId)!.push(track); + } + + // Select 1 random track from each album + const onePerAlbum: typeof allTracks = []; + for (const [albumId, tracks] of tracksByAlbum) { + const randomTrack = + tracks[Math.floor(Math.random() * tracks.length)]; + onePerAlbum.push(randomTrack); + } + + const availableAlbums = onePerAlbum.length; + const anchorCount = Math.ceil(availableAlbums * 0.2); // Add 20% anchors on top + + console.log( + ` Unique albums available: ${availableAlbums} (from ${allTracks.length} total tracks)` + ); + console.log( + ` Target composition: ${availableAlbums} discovery + ${anchorCount} anchors = ${ + availableAlbums + anchorCount + } total` + ); + + // Shuffle the unique album tracks + const shuffled = onePerAlbum.sort(() => Math.random() - 0.5); + + // Step 1: Get ALL discovery tracks (1 per album) - no limit! + let discoverySelected = [...shuffled]; + console.log( + ` Discovery tracks: ${discoverySelected.length} (ALL available, 1 per album)` + ); + + // Step 2: ALWAYS add library anchor tracks (20%) + // Get seed artists for this user + const seeds = await this.getSeedArtists(batch.userId); + const seedArtistNames = seeds.slice(0, 10).map((s) => s.name); + const seedArtistMbids = seeds + .slice(0, 10) + .map((s) => s.mbid) + .filter(Boolean) as string[]; + + let libraryAnchors: any[] = []; + // Get existing track IDs to avoid duplicates + const existingTrackIds = new Set(discoverySelected.map((t) => t.id)); + + // First, try to find library tracks from seed artists (by name or mbid) + // Also exclude albums already used in discovery + const usedAlbumIds = new Set(discoverySelected.map((t) => t.album.id)); + + if (seedArtistNames.length > 0 || seedArtistMbids.length > 0) { + const libraryTracks = await prisma.track.findMany({ + where: { + album: { + artist: { + OR: [ + { name: { in: seedArtistNames } }, + ...(seedArtistMbids.length > 0 + ? [{ mbid: { in: seedArtistMbids } }] + : []), + ], + }, + location: "LIBRARY", + id: { notIn: Array.from(usedAlbumIds) }, // Exclude albums already in discovery + }, + id: { notIn: Array.from(existingTrackIds) }, + }, + include: { + album: { include: { artist: true } }, + }, + take: anchorCount * 10, // Get extra for 1-per-album selection + }); + + console.log( + ` Found ${libraryTracks.length} candidate library tracks from ${seedArtistNames.length} seed artists` + ); + + if (libraryTracks.length > 0) { + // Group by album and pick 1 per album + const anchorsByAlbum = new Map< + string, + (typeof libraryTracks)[0] + >(); + for (const track of libraryTracks) { + if ( + !anchorsByAlbum.has(track.album.id) && + !usedAlbumIds.has(track.album.id) + ) { + anchorsByAlbum.set(track.album.id, track); + } + } + + // Shuffle and take what we need + const uniqueAnchors = Array.from(anchorsByAlbum.values()).sort( + () => Math.random() - 0.5 + ); + libraryAnchors = uniqueAnchors.slice(0, anchorCount); + + // Mark these as library anchors and track used albums + for (const track of libraryAnchors) { + (track as any).isLibraryAnchor = true; + usedAlbumIds.add(track.album.id); + } + } + } + + // GUARANTEE: If we don't have enough anchors from seed artists, use ANY popular library tracks + if (libraryAnchors.length < anchorCount) { + const needed = anchorCount - libraryAnchors.length; + console.log( + ` Only ${libraryAnchors.length}/${anchorCount} anchors from seeds, adding ${needed} from popular library tracks` + ); + + // Get track IDs we already have (discovery + current anchors) + const usedTrackIds = new Set([ + ...existingTrackIds, + ...libraryAnchors.map((t) => t.id), + ]); + + // Find popular library tracks (from artists with most plays or albums) + // Exclude albums already used + const popularLibraryTracks = await prisma.track.findMany({ + where: { + album: { + location: "LIBRARY", + id: { notIn: Array.from(usedAlbumIds) }, // 1 per album + }, + id: { notIn: Array.from(usedTrackIds) }, + }, + include: { + album: { include: { artist: true } }, + }, + orderBy: { + // Order by album's artist name for variety, or you could add play count + album: { artist: { name: "asc" } }, + }, + take: needed * 10, // Get extra for 1-per-album selection + }); + + if (popularLibraryTracks.length > 0) { + // Group by album and pick 1 per album + const popByAlbum = new Map< + string, + (typeof popularLibraryTracks)[0] + >(); + for (const track of popularLibraryTracks) { + if ( + !popByAlbum.has(track.album.id) && + !usedAlbumIds.has(track.album.id) + ) { + popByAlbum.set(track.album.id, track); + } + } + + const shuffledPopular = Array.from(popByAlbum.values()).sort( + () => Math.random() - 0.5 + ); + const additionalAnchors = shuffledPopular.slice(0, needed); + + for (const track of additionalAnchors) { + (track as any).isLibraryAnchor = true; + usedAlbumIds.add(track.album.id); + } + + libraryAnchors = [...libraryAnchors, ...additionalAnchors]; + console.log( + ` Added ${additionalAnchors.length} popular library tracks as anchors (1 per album)` + ); + } else { + console.log( + ` No additional library tracks available for anchors` + ); + } + } + + console.log( + ` Library anchors: ${libraryAnchors.length}/${anchorCount}` + ); + + // Combine ALL discovery tracks with anchors + let selected = [...discoverySelected, ...libraryAnchors]; + + // Shuffle the final selection to mix anchors with discovery + selected = selected.sort(() => Math.random() - 0.5); + + await this.addBatchLog( + batchId, + "info", + `Playlist built: ${discoverySelected.length} discovery + ${libraryAnchors.length} anchors = ${selected.length} total` + ); + + // Log final result + const target = batch.targetSongCount; // For logging purposes only + if (selected.length === 0) { + console.log(` FAILED: No tracks available for playlist`); + await this.addBatchLog( + batchId, + "error", + `No tracks available for playlist` + ); + } else if (selected.length < target) { + console.log( + ` NOTE: Got ${selected.length} tracks (target was ${target}, including ALL successful downloads)` + ); + await this.addBatchLog( + batchId, + "info", + `Got ${selected.length} tracks (target was ${target})` + ); + } else { + console.log( + ` SUCCESS: Got ${selected.length} tracks (${discoverySelected.length} discovery + ${libraryAnchors.length} anchors)` + ); + } + + // Create discovery records in transaction + let result: { albumCount: number; trackCount: number } | null = null; + try { + result = await prisma.$transaction(async (tx) => { + const createdAlbums = new Map(); + let trackCount = 0; + + for (const track of selected) { + // Use album ID as the key for deduplication (not MBID) + const albumKey = track.album.id; + let discoveryAlbumId = createdAlbums.get(albumKey); + + if (!discoveryAlbumId) { + // Find the job for this album by artist+album name (case-insensitive) + const job = completedJobs.find((j) => { + const metadata = j.metadata as any; + const jobArtist = (metadata?.artistName || "") + .toLowerCase() + .trim(); + const jobAlbum = (metadata?.albumTitle || "") + .toLowerCase() + .trim(); + const trackArtist = track.album.artist.name + .toLowerCase() + .trim(); + const trackAlbum = track.album.title + .toLowerCase() + .trim(); + return ( + jobArtist === trackArtist && + jobAlbum === trackAlbum + ); + }); + + const metadata = job?.metadata as any; + + // Use upsert to handle regeneration (records may already exist) + // IMPORTANT: Use the tier from metadata directly, don't recalculate! + // This preserves "wildcard" and other tiers that don't match their similarity + const storedTier = + metadata?.tier || + getTierFromSimilarity(metadata?.similarity || 0.5); + const storedSimilarity = metadata?.similarity || 0.5; + + // Debug: Log if job wasn't matched + if (!job) { + console.log( + ` [WARN] No job match for: ${track.album.artist.name} - ${track.album.title}` + ); + console.log( + ` Available jobs: ${completedJobs + .map( + (j) => + `${ + (j.metadata as any)?.artistName + } - ${ + (j.metadata as any)?.albumTitle + }` + ) + .slice(0, 5) + .join(", ")}...` + ); + } else { + console.log( + ` ✓ Job matched: ${ + track.album.artist.name + } - ${ + track.album.title + } (tier: ${storedTier}, similarity: ${( + storedSimilarity * 100 + ).toFixed(0)}%)` + ); + } + + const discoveryAlbum = await tx.discoveryAlbum.upsert({ + where: { + userId_weekStartDate_rgMbid: { + userId: batch.userId, + weekStartDate: batch.weekStart, + rgMbid: track.album.rgMbid, + }, + }, + create: { + userId: batch.userId, + rgMbid: track.album.rgMbid, + artistName: track.album.artist.name, + artistMbid: track.album.artist.mbid, + albumTitle: track.album.title, + lidarrAlbumId: job?.lidarrAlbumId, + similarity: storedSimilarity, + tier: storedTier, + weekStartDate: batch.weekStart, + downloadedAt: new Date(), + status: "ACTIVE", + }, + update: { + // Refresh data on regeneration + artistName: track.album.artist.name, + artistMbid: track.album.artist.mbid, + albumTitle: track.album.title, + lidarrAlbumId: job?.lidarrAlbumId, + similarity: storedSimilarity, + tier: storedTier, + downloadedAt: new Date(), + status: "ACTIVE", // Reset to active on regeneration + }, + }); + + discoveryAlbumId = discoveryAlbum.id; + createdAlbums.set(albumKey, discoveryAlbumId); + + // Add to exclusion list (if user has exclusions enabled) + const userConfig = + await tx.userDiscoverConfig.findUnique({ + where: { userId: batch.userId }, + }); + const exclusionMonths = + userConfig?.exclusionMonths ?? 6; + + if (exclusionMonths > 0) { + const expiresAt = new Date(); + expiresAt.setMonth( + expiresAt.getMonth() + exclusionMonths + ); + + await tx.discoverExclusion.upsert({ + where: { + userId_albumMbid: { + userId: batch.userId, + albumMbid: track.album.rgMbid, + }, + }, + create: { + userId: batch.userId, + albumMbid: track.album.rgMbid, + artistName: track.album.artist.name, + albumTitle: track.album.title, + expiresAt, + }, + update: { + lastSuggestedAt: new Date(), + expiresAt, + }, + }); + } + } + + await tx.discoveryTrack.create({ + data: { + discoveryAlbumId, + trackId: track.id, + fileName: track.filePath.split("/").pop() || "", + filePath: track.filePath, + }, + }); + + trackCount++; + } + + // Mark batch complete + await tx.discoveryBatch.update({ + where: { id: batchId }, + data: { + status: "completed", + finalSongCount: trackCount, + completedAt: new Date(), + }, + }); + + return { albumCount: createdAlbums.size, trackCount }; + }); + } catch (txError: any) { + console.error(` ERROR: Transaction failed:`, txError.message); + console.error(` Stack:`, txError.stack); + await this.addBatchLog( + batchId, + "error", + `Transaction failed: ${txError.message}` + ); + } + + if (result) { + console.log( + ` Playlist complete: ${result.trackCount} tracks from ${result.albumCount} albums` + ); + await this.addBatchLog( + batchId, + "info", + `Playlist complete: ${result.trackCount} tracks from ${result.albumCount} albums` + ); + } else { + console.error( + ` ERROR: Transaction returned null - no records created` + ); + await this.addBatchLog( + batchId, + "error", + "Transaction failed - no records created" + ); + } + + // ALWAYS cleanup failed artists from Lidarr (even if playlist creation failed) + // This prevents accumulating unused artists in Lidarr over time + await this.cleanupFailedArtists(batchId); + + // Also cleanup any orphaned Lidarr queue items from this batch + await this.cleanupOrphanedLidarrQueue(batchId); + } + + /** + * Cleanup orphaned Lidarr queue items that belong to this discovery batch + * but are no longer needed (download completed but album not in final playlist) + */ + private async cleanupOrphanedLidarrQueue(batchId: string): Promise { + console.log(`\n[CLEANUP] Checking for orphaned Lidarr queue items...`); + + try { + const batch = await prisma.discoveryBatch.findUnique({ + where: { id: batchId }, + include: { jobs: true }, + }); + + if (!batch) return; + + const settings = await getSystemSettings(); + if ( + !settings?.lidarrEnabled || + !settings?.lidarrUrl || + !settings?.lidarrApiKey + ) { + return; + } + + // Get all download IDs from our batch jobs + const ourDownloadIds = new Set(); + for (const job of batch.jobs) { + if (job.lidarrRef) { + ourDownloadIds.add(job.lidarrRef); + } + } + + if (ourDownloadIds.size === 0) { + console.log(` No download IDs to check`); + return; + } + + // Get Lidarr queue + const { default: axios } = await import("axios"); + const queueResponse = await axios.get( + `${settings.lidarrUrl}/api/v1/queue`, + { + params: { pageSize: 500 }, + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 30000, + } + ); + + const queueItems = queueResponse.data?.records || []; + let removed = 0; + + for (const item of queueItems) { + const downloadId = item.downloadId; + + // Check if this is one of our downloads + if (downloadId && ourDownloadIds.has(downloadId)) { + // Check if it's in a stuck state + const isStuck = + item.status === "warning" || + item.status === "failed" || + item.trackedDownloadState === "importFailed" || + item.trackedDownloadState === "importBlocked"; + + if (isStuck) { + try { + await axios.delete( + `${settings.lidarrUrl}/api/v1/queue/${item.id}`, + { + params: { + removeFromClient: true, + blocklist: true, + }, + headers: { + "X-Api-Key": settings.lidarrApiKey, + }, + timeout: 10000, + } + ); + console.log( + ` Removed orphaned queue item: ${item.title}` + ); + removed++; + } catch (e) { + // Ignore removal errors + } + } + } + } + + if (removed > 0) { + console.log(` Cleaned up ${removed} orphaned queue item(s)`); + } else { + console.log(` No orphaned queue items found`); + } + } catch (error: any) { + console.error( + `[CLEANUP] Error cleaning orphaned queue:`, + error.message + ); + } + } + + /** + * Cleanup artists from Lidarr that failed during discovery + * Only removes artists that: + * - Had ALL their downloads fail in this batch + * - Don't have any other music in the user's library + * + * NOTE: With tag-based tracking, we simply remove artists with the discovery tag + * who don't have successful downloads. The tag is the source of truth. + */ + private async cleanupFailedArtists(batchId: string): Promise { + console.log( + `\n[CLEANUP] Tag-based cleanup for failed discovery artists...` + ); + + const batch = await prisma.discoveryBatch.findUnique({ + where: { id: batchId }, + include: { jobs: true }, + }); + + if (!batch) return; + + // Build set of artists with successful downloads in this batch + const successfulArtistMbids = new Set(); + for (const job of batch.jobs) { + if (job.status === "completed") { + const metadata = job.metadata as any; + if (metadata?.artistMbid) { + successfulArtistMbids.add(metadata.artistMbid); + } + } + } + + console.log(` ${successfulArtistMbids.size} artists had successful downloads`); + + // Get all artists with the discovery tag + const discoveryArtists = await lidarrService.getDiscoveryArtists(); + console.log(` ${discoveryArtists.length} artists in Lidarr have discovery tag`); + + let removed = 0; + let kept = 0; + + for (const lidarrArtist of discoveryArtists) { + const artistMbid = lidarrArtist.foreignArtistId; + const artistName = lidarrArtist.artistName; + + if (!artistMbid) continue; + + // Keep if artist had successful downloads in this batch + if (successfulArtistMbids.has(artistMbid)) { + kept++; + continue; + } + + // Keep if artist has liked/moved discovery albums + const hasKept = await prisma.discoveryAlbum.findFirst({ + where: { + artistMbid, + status: { in: ["LIKED", "MOVED"] }, + }, + }); + + if (hasKept) { + console.log(` Keeping ${artistName} - has liked albums (removing tag)`); + await lidarrService.removeDiscoveryTagByMbid(artistMbid); + kept++; + continue; + } + + // Keep if artist has ACTIVE discovery albums from other weeks + const hasActiveOther = await prisma.discoveryAlbum.findFirst({ + where: { + artistMbid, + status: "ACTIVE", + weekStartDate: { not: batch.weekStart }, + }, + }); + + if (hasActiveOther) { + console.log(` Keeping ${artistName} - has active albums from other batches`); + kept++; + continue; + } + + // Artist has discovery tag, no successful downloads, no liked albums = remove + try { + const result = await lidarrService.deleteArtistById(lidarrArtist.id, true); + if (result.success) { + console.log(` ✓ Removed: ${artistName}`); + removed++; + } + } catch (error: any) { + console.error(` ✗ Failed to remove ${artistName}: ${error.message}`); + } + } + + console.log( + ` Cleanup complete: ${removed} removed, ${kept} kept` + ); + await this.addBatchLog( + batchId, + "info", + `Lidarr cleanup: ${removed} failed artists removed` + ); + } + + /** + * Cleanup extra albums that won't be in the final playlist + * Called when we have more successful downloads than needed + */ + private async cleanupExtraAlbums( + extraJobs: any[], + userId: string + ): Promise { + console.log( + `\n[CLEANUP] Removing ${extraJobs.length} extra albums from Lidarr and filesystem...` + ); + + const { lidarrService } = await import("./lidarr"); + + // Track artists to potentially remove (if they have no other albums) + const artistsToCheck = new Set(); + let albumsRemoved = 0; + let errors = 0; + + for (const job of extraJobs) { + const metadata = job.metadata as any; + const albumMbid = job.targetMbid; + const artistMbid = metadata?.artistMbid; + const albumTitle = metadata?.albumTitle || "Unknown"; + const artistName = metadata?.artistName || "Unknown"; + + try { + // Get Lidarr album ID if we have it + if (job.lidarrAlbumId) { + // Delete the album from Lidarr (with files) + const result = await lidarrService.deleteAlbum( + job.lidarrAlbumId, + true + ); + if (result.success) { + console.log( + ` ✓ Removed: ${artistName} - ${albumTitle}` + ); + albumsRemoved++; + + // Track artist for potential cleanup + if (artistMbid) { + artistsToCheck.add(artistMbid); + } + } else { + console.log( + ` - Skip: ${artistName} - ${albumTitle} (${result.message})` + ); + } + } else { + console.log( + ` - Skip: ${artistName} - ${albumTitle} (no Lidarr ID)` + ); + } + + // Mark the job as cancelled (not used in playlist) + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "cancelled", + error: "Extra album - not needed for playlist", + completedAt: new Date(), + }, + }); + } catch (error: any) { + console.error( + ` ✗ Error: ${artistName} - ${albumTitle}: ${error.message}` + ); + errors++; + } + } + + // Check if any artists now have no albums and should be removed + for (const artistMbid of artistsToCheck) { + try { + // Check if artist has any remaining albums in Lidarr + const albums = await lidarrService.getArtistAlbums(artistMbid); + + // Check if artist has native library content (real user library) + const hasNativeOwnedAlbums = await prisma.ownedAlbum.findFirst({ + where: { + artist: { mbid: artistMbid }, + source: "native_scan", + }, + }); + + if (!albums || (albums.length === 0 && !hasNativeOwnedAlbums)) { + // No albums left, remove artist + const result = await lidarrService.deleteArtist( + artistMbid, + true + ); + if (result.success) { + console.log(` ✓ Removed empty artist: ${artistMbid}`); + } + } + } catch (error) { + // Ignore errors when checking/removing artists + } + } + + console.log( + ` Extra album cleanup: ${albumsRemoved} removed, ${errors} errors` + ); + } + + /** + * Get seed artists from listening history + */ + private async getSeedArtists(userId: string): Promise { + const fourWeeksAgo = subWeeks(new Date(), 4); + + const recentPlays = await prisma.play.groupBy({ + by: ["trackId"], + where: { + userId, + playedAt: { gte: fourWeeksAgo }, + source: { in: ["LIBRARY", "DISCOVERY_KEPT"] }, + }, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + take: 50, + }); + + if (recentPlays.length < 5) { + // Fallback to library - get artists with most albums + const albums = await prisma.album.groupBy({ + by: ["artistId"], + where: { location: "LIBRARY" }, + _count: { id: true }, + orderBy: { _count: { id: "desc" } }, + take: 10, + }); + + const artists = await prisma.artist.findMany({ + where: { id: { in: albums.map((a) => a.artistId) } }, + }); + + return artists.map((a) => ({ name: a.name, mbid: a.mbid })); + } + + const tracks = await prisma.track.findMany({ + where: { + id: { in: recentPlays.map((p) => p.trackId) }, + // Only include tracks from LIBRARY albums, not DISCOVER + album: { location: "LIBRARY" }, + }, + include: { album: { include: { artist: true } } }, + }); + + const artistMap = new Map(); + for (const track of tracks) { + if (!artistMap.has(track.album.artistId)) { + artistMap.set(track.album.artistId, track.album.artist); + } + } + + const artists = Array.from(artistMap.values()).slice(0, 10); + return artists.map((a: any) => ({ name: a.name, mbid: a.mbid })); + } + + /** + * Check if an artist is already in the user's library + * Discovery should find NEW artists, not more albums from artists they already own + */ + private async isArtistInLibrary( + artistName: string, + artistMbid: string | undefined + ): Promise { + // Check by MBID first (most accurate) + if (artistMbid && !artistMbid.startsWith("temp-")) { + const byMbid = await prisma.artist.findFirst({ + where: { mbid: artistMbid }, + include: { albums: { take: 1 } }, + }); + if (byMbid && byMbid.albums.length > 0) { + console.log( + ` [LIBRARY] ${artistName} IN LIBRARY (matched by MBID, ${byMbid.albums.length} album(s))` + ); + return true; + } + } + + // Check by name (case insensitive) + const byName = await prisma.artist.findFirst({ + where: { + name: { equals: artistName, mode: "insensitive" }, + }, + include: { albums: { take: 1 } }, + }); + + if (byName !== null && byName.albums.length > 0) { + console.log( + ` [LIBRARY] ${artistName} IN LIBRARY (matched by name, ${byName.albums.length} album(s))` + ); + return true; + } + + return false; + } + + /** + * Check if an album is owned by artist name + album title + * This catches cases where the MBID doesn't match but the album exists + */ + private async isAlbumOwnedByName( + artistName: string, + albumTitle: string + ): Promise { + // Normalize for comparison + const normalizedArtist = artistName.toLowerCase().trim(); + const normalizedAlbum = albumTitle + .toLowerCase() + .replace(/\(.*?\)/g, "") // Remove parenthetical content + .replace(/\[.*?\]/g, "") // Remove bracketed content + .replace( + /[-–—]\s*(deluxe|remaster|bonus|special|anniversary|expanded|limited|collector).*$/i, + "" + ) + .trim(); + + // Check Album table by name + const album = await prisma.album.findFirst({ + where: { + title: { contains: normalizedAlbum, mode: "insensitive" }, + artist: { + name: { contains: normalizedArtist, mode: "insensitive" }, + }, + }, + }); + if (album) { + console.log( + ` [OWNED-NAME] Found "${albumTitle}" by "${artistName}" in Album table` + ); + return true; + } + + // Check OwnedAlbum by looking up associated Album records through rgMbid + const ownedAlbumRefs = await prisma.ownedAlbum.findMany({ + where: { + artist: { + name: { contains: normalizedArtist, mode: "insensitive" }, + }, + }, + select: { rgMbid: true }, + }); + + // Look up the actual album titles for these owned albums + if (ownedAlbumRefs.length > 0) { + const rgMbids = ownedAlbumRefs.map((o) => o.rgMbid); + const ownedAlbumRecords = await prisma.album.findMany({ + where: { rgMbid: { in: rgMbids } }, + select: { title: true }, + }); + + for (const owned of ownedAlbumRecords) { + const ownedNormalized = owned.title + ?.toLowerCase() + .replace(/\(.*?\)/g, "") + .replace(/\[.*?\]/g, "") + .trim(); + if ( + ownedNormalized && + (ownedNormalized.includes(normalizedAlbum) || + normalizedAlbum.includes(ownedNormalized)) + ) { + console.log( + ` [OWNED-NAME] Found "${albumTitle}" by "${artistName}" in OwnedAlbum table` + ); + return true; + } + } + } + + return false; + } + + /** + * Check if an album is already owned + */ + private async isAlbumOwned( + albumMbid: string, + userId: string + ): Promise { + // Check OwnedAlbum table + const ownedAlbum = await prisma.ownedAlbum.findFirst({ + where: { rgMbid: albumMbid }, + }); + if (ownedAlbum) return true; + + // Check Album table + const existingAlbum = await prisma.album.findFirst({ + where: { rgMbid: albumMbid }, + }); + if (existingAlbum) return true; + + // Check previous discovery + const previousDiscovery = await prisma.discoveryAlbum.findFirst({ + where: { rgMbid: albumMbid, userId }, + }); + if (previousDiscovery) return true; + + // Check pending downloads + const pendingDownload = await prisma.downloadJob.findFirst({ + where: { + targetMbid: albumMbid, + status: { in: ["pending", "processing"] }, + }, + }); + if (pendingDownload) return true; + + // Check Lidarr + const inLidarr = await lidarrService.isAlbumAvailable(albumMbid); + if (inLidarr) return true; + + return false; + } + + /** + * Check if album was recommended recently (6 months) + */ + private async isAlbumExcluded( + albumMbid: string, + userId: string + ): Promise { + const exclusion = await prisma.discoverExclusion.findFirst({ + where: { + userId, + albumMbid, + expiresAt: { gt: new Date() }, + }, + }); + return !!exclusion; + } + + /** + * Find a replacement album when download fails after all retries. + * Uses multi-tier fallback prioritizing ARTIST DIVERSITY: + * - Tier 2: Album from DIFFERENT similar artist (prioritize diversity!) + * - Tier 3: Another album from SAME artist (last resort fallback) + */ + async findReplacementAlbum( + failedJob: any, + batch: any + ): Promise<{ + artistName: string; + artistMbid: string; + albumTitle: string; + albumMbid: string; + similarity: number; + } | null> { + const metadata = failedJob.metadata as any; + const failedArtistMbid = metadata?.artistMbid; + + console.log( + `[Discovery] Finding replacement for: ${metadata?.artistName} - ${metadata?.albumTitle}` + ); + + // Get all MBIDs and ARTIST MBIDs already attempted in this batch (for diversity tracking) + const attemptedMbids = new Set(); + const attemptedArtistMbids = new Set(); + const batchJobs = await prisma.downloadJob.findMany({ + where: { discoveryBatchId: batch.id }, + }); + for (const job of batchJobs) { + attemptedMbids.add(job.targetMbid); + const jobMeta = job.metadata as any; + if (jobMeta?.artistMbid) { + attemptedArtistMbids.add(jobMeta.artistMbid); + } + } + + console.log( + `[Discovery] Already have ${attemptedArtistMbids.size} artists in batch, prioritizing new artists` + ); + + // Tier 2: Try album from DIFFERENT similar artist - search ALL seeds with more similar artists + // IMPORTANT: Never pick same artist twice for diversity! + console.log( + `[Discovery] Tier 2: Searching ALL seeds for albums from NEW artists (diversity enforced)` + ); + const seeds = await this.getSeedArtists(batch.userId); + + // Search ALL seeds (not just 5) to maximize chances of finding new artists + for (const seed of seeds) { + if (!seed.mbid) continue; + + try { + // Get MORE similar artists per seed (30 instead of 15) + const similarArtists = await lastFmService.getSimilarArtists( + seed.mbid, + seed.name, + 30 + ); + + for (const similar of similarArtists) { + // Skip artists we already have in this batch (including the failed artist) + if (!similar.mbid) continue; + if (similar.mbid === failedArtistMbid) continue; + if (attemptedArtistMbids.has(similar.mbid)) { + continue; // Skip - we already have an album from this artist + } + + // Get more albums to increase chances of finding available one + const albums = await lastFmService.getArtistTopAlbums( + similar.mbid, + similar.name, + 5 + ); + + for (const album of albums) { + // Get MBID from MusicBrainz + const mbAlbum = await musicBrainzService.searchAlbum( + album.name, + similar.name + ); + + if (mbAlbum && !attemptedMbids.has(mbAlbum.id)) { + // Check if artist is already in library (Discovery = NEW artists only!) + try { + const artistInLibrary = + await this.isArtistInLibrary( + similar.name, + similar.mbid + ); + if (artistInLibrary) { + console.log( + `[Discovery] Skipping ${similar.name} - already in library` + ); + continue; + } + } catch (e: any) { + console.error( + `[Discovery] isArtistInLibrary error for ${similar.name}: ${e.message}` + ); + // Continue anyway - assume not in library if check fails + } + + // Check if owned + try { + const owned = await this.isAlbumOwned( + mbAlbum.id, + batch.userId + ); + if (owned) continue; + } catch (e: any) { + console.error( + `[Discovery] isAlbumOwned error: ${e.message}` + ); + continue; // Skip on error + } + + // Check if excluded + try { + const excluded = await this.isAlbumExcluded( + mbAlbum.id, + batch.userId + ); + if (excluded) continue; + } catch (e: any) { + console.error( + `[Discovery] isAlbumExcluded error: ${e.message}` + ); + continue; // Skip on error + } + + console.log( + `[Discovery] Tier 2 replacement found: ${album.name} by ${similar.name} (NEW artist!)` + ); + return { + artistName: similar.name, + artistMbid: similar.mbid, + albumTitle: album.name, + albumMbid: mbAlbum.id, + similarity: similar.match || 0.5, + }; + } + } + } + } catch (e) { + continue; + } + } + + // NOTE: Same-artist fallback REMOVED - we enforce strict one-album-per-artist + // If we can't find a new artist, go straight to library anchor + console.log( + `[Discovery] No new artists found, using library anchor (diversity enforced)` + ); + + // Tier 3: Use track from user's library as anchor (related to discovery seeds) + console.log( + `[Discovery] Tier 3: Selecting anchor track from user's library (seed artists)` + ); + try { + // Get a random album from seed artists that user already owns + for (const seed of seeds.slice(0, 5)) { + const ownedAlbum = await prisma.album.findFirst({ + where: { + artist: { + OR: [ + { mbid: seed.mbid || "___none___" }, + { + name: { + equals: seed.name, + mode: "insensitive", + }, + }, + ], + }, + tracks: { some: {} }, // Has tracks + }, + include: { artist: true }, + }); + + if ( + ownedAlbum && + ownedAlbum.rgMbid && + !attemptedMbids.has(ownedAlbum.rgMbid) + ) { + console.log( + `[Discovery] Tier 3 anchor found: ${ownedAlbum.artist.name} - ${ownedAlbum.title} (from library)` + ); + return { + artistName: ownedAlbum.artist.name, + artistMbid: ownedAlbum.artist.mbid, + albumTitle: ownedAlbum.title, + albumMbid: ownedAlbum.rgMbid, + similarity: 1.0, // Library = perfect match + isLibraryAnchor: true, // Flag so we know not to download + } as any; + } + } + } catch (e) { + console.log( + `[Discovery] Tier 3 search failed: ${(e as Error).message}` + ); + } + + console.log(`[Discovery] No replacement found`); + return null; + } + + /** + * Find recommended albums using pre-cached similar artists + * TWO-PASS APPROACH: + * 1. First pass: Prioritize NEW artists (not in library) + * 2. Second pass: Fall back to existing artists if needed + */ + private async findRecommendedAlbums( + seeds: SeedArtist[], + similarCache: Map, + targetCount: number, + userId: string + ): Promise { + const recommendations: RecommendedAlbum[] = []; + const seenArtists = new Set(); + const seenAlbums = new Set(); + const existingArtistsForFallback: any[] = []; // Artists in library saved for second pass + + console.log(`\n Finding ${targetCount} recommended albums...`); + console.log(` Seeds: ${seeds.map((s) => s.name).join(", ")}`); + + let totalSimilarArtists = 0; + let totalAlbumsChecked = 0; + let skippedNoMbid = 0; + let skippedOwned = 0; + let skippedExcluded = 0; + let skippedDuplicate = 0; + let skippedArtistInLibrary = 0; + let addedFromExistingArtists = 0; + + // Collect all similar artists from all seeds + const allSimilarArtists: any[] = []; + for (const seed of seeds) { + const similar = similarCache.get(seed.mbid || seed.name) || []; + for (const sim of similar) { + allSimilarArtists.push(sim); + } + } + console.log( + ` Total similar artists from all seeds: ${allSimilarArtists.length}` + ); + + // ============================================ + // PASS 1: NEW ARTISTS ONLY (true discovery) + // ============================================ + console.log(`\n === PASS 1: NEW Artists Only ===`); + + for (const sim of allSimilarArtists) { + if (recommendations.length >= targetCount) break; + + const key = sim.name.toLowerCase(); + if (seenArtists.has(key)) continue; + seenArtists.add(key); + totalSimilarArtists++; + + // Check if artist is in library + let artistInLibrary = false; + try { + artistInLibrary = await this.isArtistInLibrary( + sim.name, + sim.mbid + ); + } catch (e: any) { + console.error( + ` isArtistInLibrary ERROR for ${sim.name}: ${e.message}` + ); + } + + if (artistInLibrary) { + skippedArtistInLibrary++; + existingArtistsForFallback.push(sim); // Save for second pass + continue; + } + + // Process albums for this NEW artist + const album = await this.findValidAlbumForArtist( + sim, + userId, + seenAlbums + ); + if (album) { + totalAlbumsChecked += album.albumsChecked; + skippedNoMbid += album.skippedNoMbid; + skippedOwned += album.skippedOwned; + skippedExcluded += album.skippedExcluded; + skippedDuplicate += album.skippedDuplicate; + + if (album.recommendation) { + recommendations.push(album.recommendation); + console.log( + ` ✓ ADDED (NEW): ${sim.name} - ${album.recommendation.albumTitle}` + ); + } + } + } + + console.log( + ` Pass 1 complete: ${recommendations.length}/${targetCount} from NEW artists` + ); + + // ============================================ + // PASS 2: EXISTING ARTISTS (fallback if needed) + // ============================================ + if ( + recommendations.length < targetCount && + existingArtistsForFallback.length > 0 + ) { + console.log(`\n === PASS 2: Existing Artists (fallback) ===`); + console.log( + ` Need ${targetCount - recommendations.length} more, have ${ + existingArtistsForFallback.length + } existing artists to try` + ); + + for (const sim of existingArtistsForFallback) { + if (recommendations.length >= targetCount) break; + + // Process albums for this EXISTING artist (find new albums they don't own) + const album = await this.findValidAlbumForArtist( + sim, + userId, + seenAlbums + ); + if (album) { + totalAlbumsChecked += album.albumsChecked; + skippedNoMbid += album.skippedNoMbid; + skippedOwned += album.skippedOwned; + skippedExcluded += album.skippedExcluded; + skippedDuplicate += album.skippedDuplicate; + + if (album.recommendation) { + recommendations.push(album.recommendation); + addedFromExistingArtists++; + console.log( + ` ✓ ADDED (EXISTING): ${sim.name} - ${album.recommendation.albumTitle}` + ); + } + } + } + + console.log( + ` Pass 2 complete: Added ${addedFromExistingArtists} from existing artists` + ); + } + + // Summary logging + console.log(`\n === Recommendation Summary ===`); + console.log(` Similar artists checked: ${totalSimilarArtists}`); + console.log( + ` Artists already in library (fallback pool): ${skippedArtistInLibrary}` + ); + console.log(` Albums checked: ${totalAlbumsChecked}`); + console.log(` Skipped (no MBID from MusicBrainz): ${skippedNoMbid}`); + console.log(` Skipped (album already owned): ${skippedOwned}`); + console.log( + ` Skipped (excluded - recently recommended): ${skippedExcluded}` + ); + console.log(` Skipped (duplicate): ${skippedDuplicate}`); + console.log(` ✓ Found ${recommendations.length} albums total`); + console.log( + ` - ${ + recommendations.length - addedFromExistingArtists + } from NEW artists` + ); + console.log( + ` - ${addedFromExistingArtists} from EXISTING artists (fallback)` + ); + + if (recommendations.length === 0 && totalSimilarArtists === 0) { + console.log( + ` [WARN] No similar artists found! Check Last.fm API configuration.` + ); + } else if (recommendations.length === 0 && totalAlbumsChecked === 0) { + console.log( + ` [WARN] No albums returned from Last.fm! Check getArtistTopAlbums.` + ); + } else if ( + recommendations.length === 0 && + skippedNoMbid === totalAlbumsChecked + ) { + console.log( + ` [WARN] All albums failed MusicBrainz lookup! Check searchAlbum.` + ); + } else if ( + recommendations.length === 0 && + skippedOwned >= totalAlbumsChecked + ) { + console.log( + ` [WARN] All albums already owned! Need more variety in similar artists.` + ); + } + + return recommendations; + } + + /** + * Helper: Find a valid album for a given artist + * Returns the first album that passes all checks (owned, excluded, etc.) + */ + private async findValidAlbumForArtist( + artist: any, + userId: string, + seenAlbums: Set + ): Promise<{ + recommendation: RecommendedAlbum | null; + albumsChecked: number; + skippedNoMbid: number; + skippedOwned: number; + skippedExcluded: number; + skippedDuplicate: number; + }> { + let albumsChecked = 0; + let skippedNoMbid = 0; + let skippedOwned = 0; + let skippedExcluded = 0; + let skippedDuplicate = 0; + + // Patterns to exclude non-studio releases + const EXCLUDE_PATTERNS = [ + /\blive\b/i, + /\bep\b$/i, // Only at end of title + /\bacoustic\b/i, + /\bsession[s]?\b/i, + /\bcompilation\b/i, + /\bgreatest\s*hits\b/i, + /\bbest\s*of\b/i, + /\bremix(es|ed)?\b/i, + /\bunplugged\b/i, + /\bcollection\b/i, + /\banthology\b/i, + /\bdemo[s]?\b/i, + ]; + + const isStudioAlbum = (title: string): boolean => { + return !EXCLUDE_PATTERNS.some((pattern) => pattern.test(title)); + }; + + try { + // Get 10 albums per artist (was 5) to increase chances of finding available content + const topAlbums = await lastFmService.getArtistTopAlbums( + artist.mbid || "", + artist.name, + 10 + ); + + if (topAlbums.length === 0) { + return { + recommendation: null, + albumsChecked: 0, + skippedNoMbid: 0, + skippedOwned: 0, + skippedExcluded: 0, + skippedDuplicate: 0, + }; + } + + for (const album of topAlbums) { + albumsChecked++; + + // Skip non-studio albums (live, compilations, EPs, etc.) + if (!isStudioAlbum(album.name)) { + continue; + } + + // Get MBID from MusicBrainz + const mbAlbum = await musicBrainzService.searchAlbum( + album.name, + artist.name + ); + + if (!mbAlbum) { + skippedNoMbid++; + continue; + } + + // Skip duplicates + if (seenAlbums.has(mbAlbum.id)) { + skippedDuplicate++; + continue; + } + seenAlbums.add(mbAlbum.id); + + // Skip if owned by MBID + try { + const owned = await this.isAlbumOwned(mbAlbum.id, userId); + if (owned) { + skippedOwned++; + continue; + } + } catch (e: any) { + continue; + } + + // Skip if owned by name (catches MBID mismatches) + try { + const ownedByName = await this.isAlbumOwnedByName( + artist.name, + album.name + ); + if (ownedByName) { + skippedOwned++; + continue; + } + } catch (e: any) { + continue; + } + + // Check if album was recently recommended (exclusion period) + try { + const excluded = await this.isAlbumExcluded( + mbAlbum.id, + userId + ); + if (excluded) { + skippedExcluded++; + continue; + } + } catch (e: any) { + continue; + } + + // Found a valid album! + return { + recommendation: { + artistName: artist.name, + artistMbid: artist.mbid, + albumTitle: album.name, + albumMbid: mbAlbum.id, + similarity: artist.match || 0.5, + }, + albumsChecked, + skippedNoMbid, + skippedOwned, + skippedExcluded, + skippedDuplicate, + }; + } + } catch (error: any) { + console.warn( + ` Failed to get albums for ${artist.name}: ${error.message}` + ); + } + + return { + recommendation: null, + albumsChecked, + skippedNoMbid, + skippedOwned, + skippedExcluded, + skippedDuplicate, + }; + } + + // ============================================ + // MULTI-STRATEGY DISCOVERY ENGINE + // Rotates weekly to keep recommendations fresh + // ============================================ + + /** + * Get user's top genres from listening history + */ + private async getUserTopGenres(userId: string): Promise { + try { + // Get recent plays with artist info + const recentPlays = await prisma.play.findMany({ + where: { + userId, + playedAt: { gte: subWeeks(new Date(), 12) }, // Last 3 months + }, + include: { + track: { + include: { + album: { + include: { artist: true }, + }, + }, + }, + }, + take: 500, + }); + + // Collect genres from artists (stored as tags) + const genreCounts = new Map(); + + for (const play of recentPlays) { + const artist = play.track?.album?.artist; + if (artist?.genres) { + const genres = Array.isArray(artist.genres) + ? artist.genres + : ((artist.genres as string) || "") + .split(",") + .map((g: string) => g.trim()); + + for (const genre of genres) { + if (genre && typeof genre === "string") { + genreCounts.set( + genre.toLowerCase(), + (genreCounts.get(genre.toLowerCase()) || 0) + 1 + ); + } + } + } + } + + // Sort by count and return top genres + return Array.from(genreCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([genre]) => genre); + } catch (error) { + console.error("Error getting user genres:", error); + return []; + } + } + + /** + * TAG EXPLORATION STRATEGY + * Find albums by the user's top genre tags via Last.fm + */ + private async tagExplorationStrategy( + userId: string, + targetCount: number, + seenAlbums: Set + ): Promise { + console.log( + `\n[STRATEGY] Tag Exploration - finding studio albums by genre` + ); + + const recommendations: RecommendedAlbum[] = []; + const genres = await this.getUserTopGenres(userId); + + // Patterns to exclude non-studio releases + const EXCLUDE_PATTERNS = [ + /\blive\b/i, + /\bep\b$/i, + /\bacoustic\b/i, + /\bsession[s]?\b/i, + /\bcompilation\b/i, + /\bgreatest\s*hits\b/i, + /\bbest\s*of\b/i, + /\bremix(es|ed)?\b/i, + /\bunplugged\b/i, + /\bcollection\b/i, + /\banthology\b/i, + /\bdemo[s]?\b/i, + ]; + + const isStudioAlbum = (title: string): boolean => { + return !EXCLUDE_PATTERNS.some((pattern) => pattern.test(title)); + }; + + if (genres.length === 0) { + console.log(` No genres found for user, using fallback tags`); + genres.push("rock", "indie", "alternative"); // Fallback + } + + console.log(` User's top genres: ${genres.slice(0, 5).join(", ")}`); + + for (const genre of genres.slice(0, 5)) { + if (recommendations.length >= targetCount) break; + + try { + // Use Last.fm's getTopAlbumsByTag + const tagAlbums = await lastFmService.getTopAlbumsByTag( + genre, + 30 + ); + + for (const album of tagAlbums) { + if (recommendations.length >= targetCount) break; + + const artistName = album.artist?.name || album.artist; + if (!artistName || !album.name) continue; + + // Skip non-studio albums + if (!isStudioAlbum(album.name)) continue; + + // Get MBID from MusicBrainz + const mbAlbum = await musicBrainzService.searchAlbum( + album.name, + artistName + ); + if (!mbAlbum || seenAlbums.has(mbAlbum.id)) continue; + + // Check if owned by MBID + const owned = await this.isAlbumOwned(mbAlbum.id, userId); + if (owned) continue; + + // Check if owned by name (catches MBID mismatches) + const ownedByName = await this.isAlbumOwnedByName( + artistName, + album.name + ); + if (ownedByName) continue; + + // Check if album was recently recommended (exclusion period) + const excluded = await this.isAlbumExcluded( + mbAlbum.id, + userId + ); + if (excluded) continue; + + // Check if artist is in library (prefer new artists) + const inLibrary = await this.isArtistInLibrary( + artistName, + undefined + ); + if (inLibrary) continue; + + seenAlbums.add(mbAlbum.id); + recommendations.push({ + artistName, + albumTitle: album.name, + albumMbid: mbAlbum.id, + similarity: 0.7, // Tag-based discovery + tier: "wildcard", + }); + console.log( + ` ✓ TAG: ${artistName} - ${album.name} (${genre})` + ); + } + } catch (error: any) { + console.warn( + ` Tag search failed for ${genre}: ${error.message}` + ); + } + } + + console.log( + ` Tag exploration found ${recommendations.length} albums` + ); + return recommendations; + } + + /** + * Main recommendation engine with tier-based selection + * Combines similar artists (by tier) + genre wildcards for variety + * + * Distribution: + * - 30% HIGH tier (>70% similar) + * - 40% MEDIUM tier (50-70% similar) + * - 20% EXPLORE tier (30-50% similar) + * - 10% WILDCARD (genre tags) + */ + async findRecommendedAlbumsMultiStrategy( + seeds: SeedArtist[], + similarCache: Map, + targetCount: number, + userId: string + ): Promise { + const seenAlbums = new Set(); + const seenArtists = new Set(); + const recommendations: RecommendedAlbum[] = []; + + console.log(`\n[DISCOVERY] Tier-Based Selection`); + console.log(` Target: ${targetCount} albums`); + console.log( + ` Distribution: 30% high, 40% medium, 20% explore, 10% wildcard` + ); + + // Calculate counts for each tier + const wildcardCount = Math.max( + 1, + Math.ceil(targetCount * TIER_DISTRIBUTION.wildcard) + ); + const similarArtistTarget = targetCount - wildcardCount; + + const highCount = Math.ceil( + similarArtistTarget * (TIER_DISTRIBUTION.high / 0.9) + ); + const mediumCount = Math.ceil( + similarArtistTarget * (TIER_DISTRIBUTION.medium / 0.9) + ); + const exploreCount = similarArtistTarget - highCount - mediumCount; + + console.log( + ` Targets: ${highCount} high, ${mediumCount} medium, ${exploreCount} explore, ${wildcardCount} wildcard` + ); + + // Collect all similar artists from all seeds + const allSimilarArtists: any[] = []; + for (const seed of seeds) { + const similar = similarCache.get(seed.mbid || seed.name) || []; + for (const sim of similar) { + allSimilarArtists.push(sim); + } + } + + // Group similar artists by tier (based on Last.fm match score) + // Thresholds adjusted for better distribution (Last.fm returns 0.5-0.9 range typically) + const byTier = { + high: allSimilarArtists.filter((a) => (a.match || 0) >= 0.7), + medium: allSimilarArtists.filter( + (a) => (a.match || 0) >= 0.5 && (a.match || 0) < 0.7 + ), + explore: allSimilarArtists.filter( + (a) => (a.match || 0) >= 0.3 && (a.match || 0) < 0.5 + ), + }; + + console.log( + ` Available: ${byTier.high.length} high, ${byTier.medium.length} medium, ${byTier.explore.length} explore` + ); + + // Debug: Show top artists from each tier with their match scores + if (byTier.high.length > 0) { + console.log( + ` HIGH tier sample: ${byTier.high + .slice(0, 3) + .map((a) => `${a.name}(${(a.match * 100).toFixed(0)}%)`) + .join(", ")}` + ); + } + if (byTier.medium.length > 0) { + console.log( + ` MEDIUM tier sample: ${byTier.medium + .slice(0, 3) + .map((a) => `${a.name}(${(a.match * 100).toFixed(0)}%)`) + .join(", ")}` + ); + } + if (byTier.explore.length > 0) { + console.log( + ` EXPLORE tier sample: ${byTier.explore + .slice(0, 3) + .map((a) => `${a.name}(${(a.match * 100).toFixed(0)}%)`) + .join(", ")}` + ); + } + + // Shuffle each tier for variety week-to-week + const shuffle = (arr: T[]): T[] => + [...arr].sort(() => Math.random() - 0.5); + byTier.high = shuffle(byTier.high); + byTier.medium = shuffle(byTier.medium); + byTier.explore = shuffle(byTier.explore); + + // Helper to select from a tier + const selectFromTier = async ( + tier: any[], + count: number, + tierName: "high" | "medium" | "explore" + ): Promise => { + const selected: RecommendedAlbum[] = []; + + for (const artist of tier) { + if (selected.length >= count) break; + + const key = artist.name.toLowerCase(); + if (seenArtists.has(key)) continue; + + // Check if artist is in library (prefer NEW artists) + let artistInLibrary = false; + try { + artistInLibrary = await this.isArtistInLibrary( + artist.name, + artist.mbid + ); + } catch (e) { + // Continue on error + } + + if (artistInLibrary) { + console.log(` [SKIP] ${artist.name} - in library`); + continue; + } + + // Find a valid album for this artist + const result = await this.findValidAlbumForArtist( + artist, + userId, + seenAlbums + ); + + if (result.recommendation) { + seenArtists.add(key); + result.recommendation.tier = tierName; + // Use the artist's actual match score for similarity + result.recommendation.similarity = + artist.match || result.recommendation.similarity; + selected.push(result.recommendation); + console.log( + ` ✓ [${tierName.toUpperCase()}] ${artist.name} - ${ + result.recommendation.albumTitle + } (${((artist.match || 0) * 100).toFixed(0)}%)` + ); + } + } + + return selected; + }; + + // Select from each tier + console.log(`\n === Selecting from HIGH tier ===`); + const highPicks = await selectFromTier(byTier.high, highCount, "high"); + recommendations.push(...highPicks); + + console.log(`\n === Selecting from MEDIUM tier ===`); + const mediumPicks = await selectFromTier( + byTier.medium, + mediumCount, + "medium" + ); + recommendations.push(...mediumPicks); + + console.log(`\n === Selecting from EXPLORE tier ===`); + const explorePicks = await selectFromTier( + byTier.explore, + exploreCount, + "explore" + ); + recommendations.push(...explorePicks); + + // If we didn't get enough from tiered selection, fill with any available NEW artists + if (recommendations.length < similarArtistTarget) { + console.log( + `\n === Filling remaining slots (NEW artists only) ===` + ); + const remaining = similarArtistTarget - recommendations.length; + const allRemaining = [ + ...byTier.high, + ...byTier.medium, + ...byTier.explore, + ].filter((a) => !seenArtists.has(a.name.toLowerCase())); + + for (const artist of shuffle(allRemaining)) { + if (recommendations.length >= similarArtistTarget) break; + + const key = artist.name.toLowerCase(); + if (seenArtists.has(key)) continue; + + // Check if artist is in library (same as tier selection) + let artistInLibrary = false; + try { + artistInLibrary = await this.isArtistInLibrary( + artist.name, + artist.mbid + ); + } catch (e) { + // Continue on error + } + + if (artistInLibrary) { + console.log(` [SKIP] ${artist.name} - in library`); + continue; + } + + const result = await this.findValidAlbumForArtist( + artist, + userId, + seenAlbums + ); + if (result.recommendation) { + seenArtists.add(key); + // Use the artist's actual match score for tier assignment + result.recommendation.tier = getTierFromSimilarity( + artist.match || result.recommendation.similarity + ); + // Also update similarity to use actual match score + result.recommendation.similarity = + artist.match || result.recommendation.similarity; + recommendations.push(result.recommendation); + console.log( + ` ✓ [FILL] ${artist.name} - ${ + result.recommendation.albumTitle + } (${(artist.match * 100).toFixed(0)}%)` + ); + } + } + } + + // FALLBACK: If still not enough, allow existing artists with NEW albums + if (recommendations.length < similarArtistTarget) { + console.log( + `\n === FALLBACK: Existing artists with NEW albums ===` + ); + console.log( + ` Need ${ + similarArtistTarget - recommendations.length + } more recommendations` + ); + + const allRemaining = [ + ...byTier.high, + ...byTier.medium, + ...byTier.explore, + ].filter((a) => !seenArtists.has(a.name.toLowerCase())); + + for (const artist of shuffle(allRemaining)) { + if (recommendations.length >= similarArtistTarget) break; + + const key = artist.name.toLowerCase(); + if (seenArtists.has(key)) continue; + + // This time we ALLOW artists in library - we just want NEW albums from them + const result = await this.findValidAlbumForArtist( + artist, + userId, + seenAlbums + ); + if (result.recommendation) { + seenArtists.add(key); + result.recommendation.tier = getTierFromSimilarity( + artist.match || result.recommendation.similarity + ); + result.recommendation.similarity = + artist.match || result.recommendation.similarity; + recommendations.push(result.recommendation); + console.log( + ` ✓ [EXISTING] ${artist.name} - ${ + result.recommendation.albumTitle + } (${((artist.match || 0) * 100).toFixed(0)}%)` + ); + } + } + } + + // Add genre wildcards for variety + console.log( + `\n === Adding ${wildcardCount} WILDCARD picks from genre tags ===` + ); + const wildcards = await this.tagExplorationStrategy( + userId, + wildcardCount, + seenAlbums + ); + for (const wc of wildcards) { + wc.tier = "wildcard"; + recommendations.push(wc); + } + + // Summary + const tierCounts = { + high: recommendations.filter((r) => r.tier === "high").length, + medium: recommendations.filter((r) => r.tier === "medium").length, + explore: recommendations.filter((r) => r.tier === "explore").length, + wildcard: recommendations.filter((r) => r.tier === "wildcard") + .length, + }; + + console.log(`\n[DISCOVERY] Final: ${recommendations.length} albums`); + console.log( + ` High: ${tierCounts.high}, Medium: ${tierCounts.medium}, Explore: ${tierCounts.explore}, Wildcard: ${tierCounts.wildcard}` + ); + + return recommendations.slice(0, targetCount); + } +} + +export const discoverWeeklyService = new DiscoverWeeklyService(); diff --git a/backend/src/services/discoveryLogger.ts b/backend/src/services/discoveryLogger.ts new file mode 100644 index 0000000..2d52817 --- /dev/null +++ b/backend/src/services/discoveryLogger.ts @@ -0,0 +1,226 @@ +import * as fs from "fs"; +import * as path from "path"; + +/** + * Discovery Logger - Creates detailed log files for each discovery playlist generation + */ +class DiscoveryLogger { + private logDir: string; + private currentLogFile: string | null = null; + private currentStream: fs.WriteStream | null = null; + + constructor() { + // Store logs in /app/logs/discovery (matches Dockerfile directory) + this.logDir = process.env.NODE_ENV === "production" + ? "/app/logs/discovery" + : path.join(process.cwd(), "data", "logs", "discovery"); + } + + /** + * Start a new log file for a discovery generation + */ + start(userId: string, jobId?: number): string { + // Ensure log directory exists + if (!fs.existsSync(this.logDir)) { + fs.mkdirSync(this.logDir, { recursive: true }); + } + + // Create filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `discovery-${timestamp}-job${jobId || "manual"}.log`; + this.currentLogFile = path.join(this.logDir, filename); + + // Open write stream + this.currentStream = fs.createWriteStream(this.currentLogFile, { flags: "a" }); + + // Write header + this.write("═".repeat(60)); + this.write(`DISCOVERY WEEKLY GENERATION LOG`); + this.write(`Started: ${new Date().toISOString()}`); + this.write(`User ID: ${userId}`); + this.write(`Job ID: ${jobId || "manual"}`); + this.write("═".repeat(60)); + this.write(""); + + return this.currentLogFile; + } + + /** + * Write a line to the current log + */ + write(message: string, indent: number = 0): void { + const prefix = " ".repeat(indent); + const timestamp = new Date().toISOString().split("T")[1].split(".")[0]; + const line = `[${timestamp}] ${prefix}${message}`; + + // Write to file + if (this.currentStream) { + this.currentStream.write(line + "\n"); + } + + // Also write to console for real-time visibility + console.log(message); + } + + /** + * Write a section header + */ + section(title: string): void { + this.write(""); + this.write("─".repeat(50)); + this.write(`> ${title}`); + this.write("─".repeat(50)); + } + + /** + * Write a success message + */ + success(message: string, indent: number = 0): void { + this.write(`✓ ${message}`, indent); + } + + /** + * Write an error message + */ + error(message: string, indent: number = 0): void { + this.write(`✗ ${message}`, indent); + } + + /** + * Write a warning message + */ + warn(message: string, indent: number = 0): void { + this.write(`[WARN] ${message}`, indent); + } + + /** + * Write info message + */ + info(message: string, indent: number = 0): void { + this.write(`ℹ ${message}`, indent); + } + + /** + * Write a table of key-value pairs + */ + table(data: Record, indent: number = 1): void { + for (const [key, value] of Object.entries(data)) { + this.write(`${key}: ${value}`, indent); + } + } + + /** + * Write a list of items + */ + list(items: string[], indent: number = 1): void { + for (const item of items) { + this.write(`• ${item}`, indent); + } + } + + /** + * End the current log and close the stream + */ + end(success: boolean, summary?: string): void { + this.write(""); + this.write("═".repeat(60)); + this.write(`GENERATION ${success ? "COMPLETED" : "FAILED"}`); + if (summary) { + this.write(summary); + } + this.write(`Ended: ${new Date().toISOString()}`); + this.write("═".repeat(60)); + + if (this.currentStream) { + this.currentStream.end(); + this.currentStream = null; + } + } + + /** + * Get the path to the current log file + */ + getCurrentLogPath(): string | null { + return this.currentLogFile; + } + + /** + * Get the most recent log file + */ + getLatestLog(): { path: string; content: string } | null { + if (!fs.existsSync(this.logDir)) { + return null; + } + + const files = fs.readdirSync(this.logDir) + .filter(f => f.startsWith("discovery-") && f.endsWith(".log")) + .sort() + .reverse(); + + if (files.length === 0) { + return null; + } + + const latestPath = path.join(this.logDir, files[0]); + const content = fs.readFileSync(latestPath, "utf-8"); + + return { path: latestPath, content }; + } + + /** + * Get all log files (most recent first) + */ + getAllLogs(): { filename: string; date: Date; size: number }[] { + if (!fs.existsSync(this.logDir)) { + return []; + } + + return fs.readdirSync(this.logDir) + .filter(f => f.startsWith("discovery-") && f.endsWith(".log")) + .map(filename => { + const filePath = path.join(this.logDir, filename); + const stats = fs.statSync(filePath); + return { + filename, + date: stats.mtime, + size: stats.size + }; + }) + .sort((a, b) => b.date.getTime() - a.date.getTime()); + } + + /** + * Get a specific log file content + */ + getLogContent(filename: string): string | null { + const filePath = path.join(this.logDir, filename); + if (!fs.existsSync(filePath)) { + return null; + } + return fs.readFileSync(filePath, "utf-8"); + } + + /** + * Clean up old logs (keep last N) + */ + cleanup(keepCount: number = 20): number { + const logs = this.getAllLogs(); + let deleted = 0; + + for (let i = keepCount; i < logs.length; i++) { + const filePath = path.join(this.logDir, logs[i].filename); + fs.unlinkSync(filePath); + deleted++; + } + + return deleted; + } +} + +export const discoveryLogger = new DiscoveryLogger(); + + + + + + diff --git a/backend/src/services/downloadQueue.ts b/backend/src/services/downloadQueue.ts new file mode 100644 index 0000000..dfdf90f --- /dev/null +++ b/backend/src/services/downloadQueue.ts @@ -0,0 +1,658 @@ +interface DownloadInfo { + downloadId: string; + albumTitle: string; + albumMbid: string; + artistName: string; + artistMbid?: string; + albumId?: number; + artistId?: number; + attempts: number; + startTime: number; + userId?: string; + tier?: string; + similarity?: number; +} + +type UnavailableAlbumCallback = (info: { + albumTitle: string; + artistName: string; + albumMbid: string; + artistMbid?: string; + userId?: string; + tier?: string; + similarity?: number; +}) => Promise; + +class DownloadQueueManager { + private activeDownloads = new Map(); + private timeoutTimer: NodeJS.Timeout | null = null; + private cleanupInterval: NodeJS.Timeout | null = null; + private readonly TIMEOUT_MINUTES = 10; // Trigger scan after 10 minutes regardless + private readonly MAX_RETRY_ATTEMPTS = 3; // Max retries before giving up + private readonly STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - entries older than this are considered stale + private unavailableCallbacks: UnavailableAlbumCallback[] = []; + + constructor() { + // Start periodic cleanup of stale downloads (every 5 minutes) + this.cleanupInterval = setInterval(() => { + this.cleanupStaleDownloads(); + }, 5 * 60 * 1000); + } + + /** + * Track a new download + */ + addDownload( + downloadId: string, + albumTitle: string, + albumMbid: string, + artistName: string, + albumId?: number, + artistId?: number, + options?: { + artistMbid?: string; + userId?: string; + tier?: string; + similarity?: number; + } + ) { + const info: DownloadInfo = { + downloadId, + albumTitle, + albumMbid, + artistName, + artistMbid: options?.artistMbid, + albumId, + artistId, + attempts: 1, + startTime: Date.now(), + userId: options?.userId, + tier: options?.tier, + similarity: options?.similarity, + }; + + this.activeDownloads.set(downloadId, info); + console.log( + `[DOWNLOAD] Started: "${albumTitle}" by ${artistName} (${downloadId})` + ); + console.log(` Album MBID: ${albumMbid}`); + console.log(` Active downloads: ${this.activeDownloads.size}`); + + // Persist Lidarr download reference to download job for later status updates + this.linkDownloadJob(downloadId, albumMbid).catch((error) => { + console.error(` linkDownloadJob error:`, error); + }); + + // Start timeout on first download + if (this.activeDownloads.size === 1 && !this.timeoutTimer) { + this.startTimeout(); + } + } + + /** + * Register a callback to be notified when an album is unavailable + */ + onUnavailableAlbum(callback: UnavailableAlbumCallback) { + this.unavailableCallbacks.push(callback); + } + + /** + * Clear all unavailable album callbacks + */ + clearUnavailableCallbacks() { + this.unavailableCallbacks = []; + } + + /** + * Mark download as complete + */ + async completeDownload(downloadId: string, albumTitle: string) { + this.activeDownloads.delete(downloadId); + console.log(`Download complete: "${albumTitle}" (${downloadId})`); + console.log(` Remaining downloads: ${this.activeDownloads.size}`); + + // If no more downloads, trigger refresh immediately + if (this.activeDownloads.size === 0) { + console.log(`⏰ All downloads complete! Starting refresh now...`); + this.clearTimeout(); + this.triggerFullRefresh(); + } + } + + /** + * Mark download as failed and optionally retry + */ + async failDownload(downloadId: string, reason: string) { + const info = this.activeDownloads.get(downloadId); + if (!info) { + console.log( + ` Download ${downloadId} not tracked, ignoring failure` + ); + return; + } + + console.log(` Download failed: "${info.albumTitle}" (${downloadId})`); + console.log(` Reason: ${reason}`); + console.log(` Attempt ${info.attempts}/${this.MAX_RETRY_ATTEMPTS}`); + + // Check if we should retry + if (info.attempts < this.MAX_RETRY_ATTEMPTS) { + info.attempts++; + console.log(` Retrying download... (attempt ${info.attempts})`); + await this.retryDownload(info); + } else { + console.log(` ⛔ Max retry attempts reached, giving up`); + await this.cleanupFailedAlbum(info); + this.activeDownloads.delete(downloadId); + + // Check if all downloads are done + if (this.activeDownloads.size === 0) { + console.log( + `⏰ All downloads finished (some failed). Starting refresh...` + ); + this.clearTimeout(); + this.triggerFullRefresh(); + } + } + } + + /** + * Retry a failed download by triggering Lidarr album search + */ + private async retryDownload(info: DownloadInfo) { + try { + if (!info.albumId) { + console.log(` No album ID, cannot retry`); + return; + } + + const { getSystemSettings } = await import( + "../utils/systemSettings" + ); + const settings = await getSystemSettings(); + + if ( + !settings.lidarrEnabled || + !settings.lidarrUrl || + !settings.lidarrApiKey + ) { + console.log(` Lidarr not configured`); + return; + } + + const axios = (await import("axios")).default; + + // Trigger new album search + await axios.post( + `${settings.lidarrUrl}/api/v1/command`, + { + name: "AlbumSearch", + albumIds: [info.albumId], + }, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + + console.log(` Retry search triggered in Lidarr`); + } catch (error: any) { + console.log(` Failed to retry: ${error.message}`); + } + } + + /** + * Clean up failed album from Lidarr and Discovery database + */ + private async cleanupFailedAlbum(info: DownloadInfo) { + try { + console.log(` Cleaning up failed album: ${info.albumTitle}`); + + const { getSystemSettings } = await import( + "../utils/systemSettings" + ); + const settings = await getSystemSettings(); + + if ( + !settings.lidarrEnabled || + !settings.lidarrUrl || + !settings.lidarrApiKey + ) { + return; + } + + const axios = (await import("axios")).default; + + // Delete album from Lidarr + if (info.albumId) { + try { + await axios.delete( + `${settings.lidarrUrl}/api/v1/album/${info.albumId}`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + console.log(` Removed album from Lidarr`); + } catch (error: any) { + console.log(` Failed to remove album: ${error.message}`); + } + } + + // Check if artist has any other albums + if (info.artistId) { + try { + const artistResponse = await axios.get( + `${settings.lidarrUrl}/api/v1/artist/${info.artistId}`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + + const artist = artistResponse.data; + const monitoredAlbums = + artist.albums?.filter((a: any) => a.monitored) || []; + + // If no other monitored albums, remove artist + if (monitoredAlbums.length === 0) { + await axios.delete( + `${settings.lidarrUrl}/api/v1/artist/${info.artistId}`, + { + params: { deleteFiles: false }, + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + console.log( + ` Removed artist from Lidarr (no other albums)` + ); + } + } catch (error: any) { + console.log( + ` Failed to check/remove artist: ${error.message}` + ); + } + } + + // Mark as failed in Discovery database + const { prisma } = await import("../utils/db"); + await prisma.discoveryAlbum.updateMany({ + where: { albumTitle: info.albumTitle }, + data: { status: "FAILED" }, + }); + console.log(` Marked as failed in database`); + + // Notify callbacks about unavailable album + console.log( + ` [NOTIFY] Notifying ${this.unavailableCallbacks.length} callbacks about unavailable album` + ); + for (const callback of this.unavailableCallbacks) { + try { + await callback({ + albumTitle: info.albumTitle, + artistName: info.artistName, + albumMbid: info.albumMbid, + artistMbid: info.artistMbid, + userId: info.userId, + tier: info.tier, + similarity: info.similarity, + }); + } catch (error: any) { + console.log(` Callback error: ${error.message}`); + } + } + } catch (error: any) { + console.log(` Cleanup error: ${error.message}`); + } + } + + /** + * Start timeout to trigger scan after X minutes even if downloads are still pending + */ + private startTimeout() { + const timeoutMs = this.TIMEOUT_MINUTES * 60 * 1000; + console.log( + `[TIMER] Starting ${this.TIMEOUT_MINUTES}-minute timeout for automatic scan` + ); + + this.timeoutTimer = setTimeout(() => { + if (this.activeDownloads.size > 0) { + console.log( + `\n Timeout reached! ${this.activeDownloads.size} downloads still pending.` + ); + console.log(` These downloads never completed:`); + + // Mark each pending download as failed to trigger callbacks + for (const [downloadId, info] of this.activeDownloads) { + console.log( + ` - ${info.albumTitle} by ${info.artistName}` + ); + // This will trigger the unavailable album callback + this.failDownload( + downloadId, + "Download timeout - never completed" + ).catch((err) => { + console.error( + `Error failing download ${downloadId}:`, + err + ); + }); + } + + console.log( + ` Triggering scan anyway to process completed downloads...\n` + ); + } else { + this.triggerFullRefresh(); + } + }, timeoutMs); + } + + /** + * Clear the timeout timer + */ + private clearTimeout() { + if (this.timeoutTimer) { + clearTimeout(this.timeoutTimer); + this.timeoutTimer = null; + } + } + + /** + * Trigger full library refresh (Lidarr cleanup → Lidify sync) + */ + private async triggerFullRefresh() { + try { + console.log("\n Starting full library refresh...\n"); + + // Step 1: Clear failed imports from Lidarr + console.log("[1/2] Checking for failed imports in Lidarr..."); + await this.clearFailedLidarrImports(); + + // Step 2: Trigger Lidify library sync + console.log("[2/2] Triggering Lidify library sync..."); + const lidifySuccess = await this.triggerLidifySync(); + + if (!lidifySuccess) { + console.error(" Lidify sync failed"); + return; + } + + console.log("Lidify sync started"); + console.log( + "\n[SUCCESS] Full library refresh complete! New music should appear shortly.\n" + ); + } catch (error) { + console.error(" Library refresh error:", error); + } + } + + /** + * Clear failed imports from Lidarr queue + */ + private async clearFailedLidarrImports(): Promise { + try { + const { getSystemSettings } = await import( + "../utils/systemSettings" + ); + const settings = await getSystemSettings(); + + if (!settings.lidarrEnabled || !settings.lidarrUrl) { + console.log(" Lidarr not configured, skipping"); + return; + } + + const axios = (await import("axios")).default; + + // Get Lidarr API key + const apiKey = settings.lidarrApiKey; + if (!apiKey) { + console.log(" Lidarr API key not found, skipping"); + return; + } + + // Get queue + const response = await axios.get( + `${settings.lidarrUrl}/api/v1/queue`, + { + headers: { "X-Api-Key": apiKey }, + timeout: 10000, + } + ); + + const queue = response.data.records || []; + + // Find failed imports + const failed = queue.filter( + (item: any) => + item.trackedDownloadStatus === "warning" || + item.trackedDownloadStatus === "error" || + item.status === "warning" || + item.status === "failed" + ); + + if (failed.length === 0) { + console.log(" No failed imports found"); + return; + } + + console.log(` Found ${failed.length} failed import(s)`); + + for (const item of failed) { + const artistName = + item.artist?.artistName || item.artist?.name || "Unknown"; + const albumTitle = + item.album?.title || item.album?.name || "Unknown Album"; + + console.log(` ${artistName} - ${albumTitle}`); + + try { + // Remove from queue, blocklist, and trigger search + await axios.delete( + `${settings.lidarrUrl}/api/v1/queue/${item.id}`, + { + params: { + removeFromClient: true, + blocklist: true, + }, + headers: { "X-Api-Key": apiKey }, + timeout: 10000, + } + ); + + // Trigger new search if album ID is available + if (item.album?.id) { + await axios.post( + `${settings.lidarrUrl}/api/v1/command`, + { + name: "AlbumSearch", + albumIds: [item.album.id], + }, + { + headers: { "X-Api-Key": apiKey }, + timeout: 10000, + } + ); + console.log( + ` → Blocklisted and searching for alternative` + ); + } else { + console.log( + ` → Blocklisted (no album ID for re-search)` + ); + } + } catch (error: any) { + console.log(` Failed to process: ${error.message}`); + } + } + + console.log(` Cleared ${failed.length} failed import(s)`); + } catch (error: any) { + console.log(` Failed to check Lidarr queue: ${error.message}`); + } + } + + /** + * Trigger Lidify library sync + */ + private async triggerLidifySync(): Promise { + try { + const { scanQueue } = await import("../workers/queues"); + const { prisma } = await import("../utils/db"); + + console.log(" Starting library scan..."); + + // Get first user for scanning + const firstUser = await prisma.user.findFirst(); + if (!firstUser) { + console.error(` No users found in database, cannot scan`); + return false; + } + + // Trigger scan via queue + await scanQueue.add("scan", { + userId: firstUser.id, + source: "download-queue", + }); + + console.log("Library scan queued"); + return true; + } catch (error: any) { + console.error("Lidify sync trigger error:", error.message); + return false; + } + } + + /** + * Get current queue status + */ + getStatus() { + return { + activeDownloads: this.activeDownloads.size, + downloads: Array.from(this.activeDownloads.values()), + timeoutActive: this.timeoutTimer !== null, + }; + } + + /** + * Get the active downloads map (for checking if a download is being tracked) + */ + getActiveDownloads() { + return this.activeDownloads; + } + + /** + * Manually trigger a full refresh (for testing or manual triggers) + */ + async manualRefresh() { + console.log("\n Manual refresh triggered...\n"); + await this.triggerFullRefresh(); + } + + /** + * Clean up stale downloads that have been active for too long + * This prevents the activeDownloads Map from growing unbounded + */ + cleanupStaleDownloads(): number { + const now = Date.now(); + let cleanedCount = 0; + + for (const [downloadId, info] of this.activeDownloads) { + const age = now - info.startTime; + if (age > this.STALE_TIMEOUT_MS) { + console.log( + `[CLEANUP] Cleaning up stale download: "${ + info.albumTitle + }" (${downloadId}) - age: ${Math.round( + age / 60000 + )} minutes` + ); + this.activeDownloads.delete(downloadId); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + console.log( + `[CLEANUP] Cleaned up ${cleanedCount} stale download(s)` + ); + } + + return cleanedCount; + } + + /** + * Shutdown the download queue manager (cleanup resources) + */ + shutdown() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.clearTimeout(); + this.activeDownloads.clear(); + console.log("Download queue manager shutdown"); + } + + /** + * Link Lidarr download IDs to download jobs (so we can mark them completed later) + */ + private async linkDownloadJob(downloadId: string, albumMbid: string) { + console.log( + ` [LINK] Attempting to link download job for MBID: ${albumMbid}` + ); + try { + const { prisma } = await import("../utils/db"); + + // Debug: Check if job exists + const existingJobs = await prisma.downloadJob.findMany({ + where: { targetMbid: albumMbid }, + select: { + id: true, + status: true, + lidarrRef: true, + targetMbid: true, + }, + }); + console.log( + ` [LINK] Found ${existingJobs.length} job(s) with this MBID:`, + JSON.stringify(existingJobs, null, 2) + ); + + const result = await prisma.downloadJob.updateMany({ + where: { + targetMbid: albumMbid, + status: { in: ["pending", "processing"] }, + OR: [{ lidarrRef: null }, { lidarrRef: "" }], + }, + data: { + lidarrRef: downloadId, + status: "processing", + }, + }); + + if (result.count === 0) { + console.log( + ` No matching download jobs found to link with Lidarr ID ${downloadId}` + ); + console.log( + ` This means either: no job exists, job already has lidarrRef, or status is not pending/processing` + ); + } else { + console.log( + ` Linked Lidarr download ${downloadId} to ${result.count} download job(s)` + ); + } + } catch (error: any) { + console.error( + ` Failed to persist Lidarr download link:`, + error.message + ); + console.error(` Error details:`, error); + } + } +} + +// Singleton instance +export const downloadQueueManager = new DownloadQueueManager(); diff --git a/backend/src/services/enrichment.ts b/backend/src/services/enrichment.ts new file mode 100644 index 0000000..fc92c9e --- /dev/null +++ b/backend/src/services/enrichment.ts @@ -0,0 +1,664 @@ +/** + * Metadata Enrichment Service + * + * Enriches artist/album/track metadata using multiple sources: + * - MusicBrainz: MBIDs, release dates, track info + * - Last.fm: Genres, tags, similar artists, bio + * - Cover Art Archive: Album artwork + * - Discogs: Additional metadata (optional) + * + * Features: + * - Optional/opt-in (bandwidth intensive) + * - Rate limiting to respect API limits + * - Confidence scoring for matches + * - Manual override support + */ + +import { prisma } from "../utils/db"; +import { lastFmService } from "./lastfm"; +import { musicBrainzService } from "./musicbrainz"; +import { imageProviderService } from "./imageProvider"; + +export interface EnrichmentSettings { + enabled: boolean; + autoEnrichOnScan: boolean; + sources: { + musicbrainz: boolean; + lastfm: boolean; + coverArtArchive: boolean; + }; + rateLimit: { + maxRequestsPerMinute: number; + respectApiLimits: boolean; + }; + overwriteExisting: boolean; + matchingConfidence: "strict" | "moderate" | "loose"; +} + +export interface EnrichmentResult { + success: boolean; + itemsProcessed: number; + itemsEnriched: number; + itemsFailed: number; + errors: Array<{ item: string; error: string }>; +} + +export interface ArtistEnrichmentData { + mbid?: string; + bio?: string; + genres?: string[]; + tags?: string[]; + similarArtists?: string[]; + heroUrl?: string; + formed?: number; + confidence: number; +} + +export interface AlbumEnrichmentData { + rgMbid?: string; + releaseDate?: Date; + albumType?: string; + genres?: string[]; + tags?: string[]; + label?: string; + coverUrl?: string; + trackCount?: number; + confidence: number; +} + +export interface TrackEnrichmentData { + mbid?: string; + duration?: number; + genres?: string[]; + lyrics?: string; + confidence: number; +} + +export class EnrichmentService { + private defaultSettings: EnrichmentSettings = { + enabled: false, // Opt-in by default + autoEnrichOnScan: false, + sources: { + musicbrainz: true, + lastfm: true, + coverArtArchive: true, + }, + rateLimit: { + maxRequestsPerMinute: 30, + respectApiLimits: true, + }, + overwriteExisting: false, + matchingConfidence: "moderate", + }; + + private requestQueue: Array<() => Promise> = []; + private isProcessingQueue = false; + + /** + * Get enrichment settings for a user + */ + async getSettings(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { enrichmentSettings: true }, + }); + + if (user?.enrichmentSettings) { + // enrichmentSettings is already a JSON object from Prisma + let userSettings: any; + if (typeof user.enrichmentSettings === "string") { + userSettings = JSON.parse(user.enrichmentSettings); + } else { + userSettings = user.enrichmentSettings; + } + + // IMPORTANT: Always merge with defaults to ensure all fields exist + return { + ...this.defaultSettings, + ...userSettings, + sources: { + ...this.defaultSettings.sources, + ...(userSettings.sources || {}), + }, + rateLimit: { + ...this.defaultSettings.rateLimit, + ...(userSettings.rateLimit || {}), + }, + }; + } + + return this.defaultSettings; + } + + /** + * Update enrichment settings for a user + */ + async updateSettings( + userId: string, + settings: Partial + ): Promise { + const current = await this.getSettings(userId); + const updated = { ...current, ...settings }; + + await prisma.user.update({ + where: { id: userId }, + data: { + enrichmentSettings: JSON.stringify(updated) as any, + }, + }); + + return updated; + } + + /** + * Enrich a single artist with metadata from multiple sources + */ + async enrichArtist( + artistId: string, + settings?: EnrichmentSettings + ): Promise { + const config = settings || this.defaultSettings; + if (!config.enabled) { + return null; + } + + const artist = await prisma.artist.findUnique({ + where: { id: artistId }, + select: { id: true, name: true, mbid: true }, + }); + + if (!artist) { + throw new Error(`Artist ${artistId} not found`); + } + + console.log(`Enriching artist: ${artist.name}`); + + const enrichmentData: ArtistEnrichmentData = { + confidence: 0, + }; + + // Step 1: Get/verify MBID from MusicBrainz + if ( + config.sources.musicbrainz && + (!artist.mbid || artist.mbid.startsWith("temp-")) + ) { + try { + const mbResults = await musicBrainzService.searchArtist( + artist.name, + 1 + ); + if (mbResults.length > 0) { + enrichmentData.mbid = mbResults[0].id; + enrichmentData.confidence += 0.4; + console.log(` Found MBID: ${enrichmentData.mbid}`); + } + } catch (error) { + console.error(` ✗ MusicBrainz lookup failed:`, error); + } + } + + // Step 2: Get artist info from Last.fm + if (config.sources.lastfm) { + try { + const artistMbid = enrichmentData.mbid || artist.mbid; + const lastfmInfo = await lastFmService.getArtistInfo( + artist.name, + artistMbid && !artistMbid.startsWith("temp-") + ? artistMbid + : undefined + ); + + if (lastfmInfo) { + enrichmentData.bio = lastfmInfo.bio?.summary; + enrichmentData.tags = + lastfmInfo.tags?.tag?.map((t: any) => t.name) || []; + enrichmentData.genres = enrichmentData.tags?.slice(0, 3); // Top 3 tags as genres + enrichmentData.confidence += 0.3; + console.log( + ` Found Last.fm data: ${ + enrichmentData.tags?.length || 0 + } tags` + ); + + // Get similar artists + const similar = await lastFmService.getSimilarArtists( + artist.name, + "10" + ); + enrichmentData.similarArtists = similar.map( + (a: any) => a.name + ); + console.log(` Found ${similar.length} similar artists`); + } + } catch (error) { + console.error( + ` ✗ Last.fm lookup failed:`, + error instanceof Error ? error.message : error + ); + } + } + + // Step 3: Get artist image from multiple sources (Deezer → Fanart → MusicBrainz → Last.fm) + try { + const artistMbid = enrichmentData.mbid || artist.mbid; + const imageResult = await imageProviderService.getArtistImage( + artist.name, + artistMbid && !artistMbid.startsWith("temp-") + ? artistMbid + : undefined + ); + + if (imageResult) { + enrichmentData.heroUrl = imageResult.url; + enrichmentData.confidence += 0.2; + console.log(` Found artist image from ${imageResult.source}`); + } + } catch (error) { + console.error( + ` ✗ Artist image lookup failed:`, + error instanceof Error ? error.message : error + ); + } + + console.log( + ` Enrichment confidence: ${( + enrichmentData.confidence * 100 + ).toFixed(0)}%` + ); + + return enrichmentData; + } + + /** + * Enrich a single album with metadata from multiple sources + */ + async enrichAlbum( + albumId: string, + settings?: EnrichmentSettings + ): Promise { + const config = settings || this.defaultSettings; + if (!config.enabled) { + return null; + } + + const album = await prisma.album.findUnique({ + where: { id: albumId }, + include: { + artist: { + select: { name: true, mbid: true }, + }, + }, + }); + + if (!album) { + throw new Error(`Album ${albumId} not found`); + } + + console.log( + `[Enrichment] Processing album: ${album.artist.name} - ${album.title}` + ); + + const enrichmentData: AlbumEnrichmentData = { + confidence: 0, + }; + + // Step 1: Try to find MBID + if (config.sources.musicbrainz) { + try { + // If artist has MBID, search their discography + if ( + album.artist.mbid && + !album.artist.mbid.startsWith("temp-") + ) { + const releaseGroups = + await musicBrainzService.getReleaseGroups( + album.artist.mbid, + ["album", "ep"], + 50 + ); + + // Try to match by title + const match = releaseGroups.find( + (rg: any) => + rg.title.toLowerCase() === + album.title.toLowerCase() || + rg.title.toLowerCase().replace(/[^a-z0-9]/g, "") === + album.title + .toLowerCase() + .replace(/[^a-z0-9]/g, "") + ); + + if (match) { + enrichmentData.rgMbid = match.id; + enrichmentData.albumType = match["primary-type"]; + enrichmentData.releaseDate = match["first-release-date"] + ? new Date(match["first-release-date"]) + : undefined; + enrichmentData.confidence += 0.5; + console.log(` Found MBID: ${enrichmentData.rgMbid}`); + + // Try to get label info from first release + try { + const rgDetails = + await musicBrainzService.getReleaseGroup( + match.id + ); + if (rgDetails?.releases?.[0]?.id) { + const releaseId = rgDetails.releases[0].id; + const releaseInfo = + await musicBrainzService.getRelease( + releaseId + ); + if ( + releaseInfo?.["label-info"]?.[0]?.label + ?.name + ) { + enrichmentData.label = + releaseInfo["label-info"][0].label.name; + console.log( + ` Found label: ${enrichmentData.label}` + ); + } + } + } catch (error) { + console.log(`Could not fetch label info`); + } + } + } + } catch (error) { + console.error(` ✗ MusicBrainz lookup failed:`, error); + } + } + + // Step 2: Get album info from Last.fm + if (config.sources.lastfm) { + try { + const lastfmInfo = await lastFmService.getAlbumInfo( + album.artist.name, + album.title, + enrichmentData.rgMbid + ); + + if (lastfmInfo) { + enrichmentData.tags = + lastfmInfo.tags?.tag?.map((t: any) => t.name) || []; + enrichmentData.genres = enrichmentData.tags?.slice(0, 3); + enrichmentData.trackCount = + lastfmInfo.tracks?.track?.length; + enrichmentData.confidence += 0.3; + console.log( + ` Found Last.fm data: ${ + enrichmentData.tags?.length || 0 + } tags` + ); + } + } catch (error) { + console.error(` ✗ Last.fm lookup failed:`, error); + } + } + + // Step 3: Get cover art from multiple sources (Deezer → MusicBrainz → Fanart) + try { + const coverResult = await imageProviderService.getAlbumCover( + album.artist.name, + album.title, + enrichmentData.rgMbid + ); + + if (coverResult) { + enrichmentData.coverUrl = coverResult.url; + enrichmentData.confidence += 0.2; + console.log(` Found cover art from ${coverResult.source}`); + } + } catch (error) { + console.error( + ` ✗ Cover art lookup failed:`, + error instanceof Error ? error.message : error + ); + } + + console.log( + ` Enrichment confidence: ${( + enrichmentData.confidence * 100 + ).toFixed(0)}%` + ); + + return enrichmentData; + } + + /** + * Apply enrichment data to an artist in the database + */ + async applyArtistEnrichment( + artistId: string, + data: ArtistEnrichmentData + ): Promise { + const updateData: any = {}; + + // Check if MBID is already in use by another artist + if (data.mbid) { + const existingArtist = await prisma.artist.findUnique({ + where: { mbid: data.mbid }, + select: { id: true, name: true }, + }); + + if (existingArtist && existingArtist.id !== artistId) { + console.log( + `MBID ${data.mbid} already used by "${existingArtist.name}", skipping MBID update` + ); + } else { + updateData.mbid = data.mbid; + } + } + + if (data.bio) updateData.summary = data.bio; + if (data.heroUrl) updateData.heroUrl = data.heroUrl; + if (data.genres && data.genres.length > 0) { + updateData.genres = data.genres; + } + + if (Object.keys(updateData).length > 0) { + await prisma.artist.update({ + where: { id: artistId }, + data: updateData, + }); + console.log( + ` Saved ${data.genres?.length || 0} genres for artist` + ); + } + } + + /** + * Apply enrichment data to an album in the database + */ + async applyAlbumEnrichment( + albumId: string, + data: AlbumEnrichmentData + ): Promise { + const updateData: any = {}; + + if (data.rgMbid) updateData.rgMbid = data.rgMbid; + if (data.coverUrl) updateData.coverUrl = data.coverUrl; + if (data.releaseDate) { + updateData.year = data.releaseDate.getFullYear(); + } + if (data.label) updateData.label = data.label; + if (data.genres && data.genres.length > 0) { + updateData.genres = data.genres; + } + + if (Object.keys(updateData).length > 0) { + await prisma.album.update({ + where: { id: albumId }, + data: updateData, + }); + console.log( + ` Saved album data: ${ + data.genres?.length || 0 + } genres, label: ${data.label || "none"}` + ); + } + + // Update OwnedAlbum table if MBID changed + if (data.rgMbid) { + const album = await prisma.album.findUnique({ + where: { id: albumId }, + select: { artistId: true }, + }); + + if (album) { + await prisma.ownedAlbum.upsert({ + where: { + artistId_rgMbid: { + artistId: album.artistId, + rgMbid: data.rgMbid, + }, + }, + create: { + artistId: album.artistId, + rgMbid: data.rgMbid, + source: "enrichment", + }, + update: {}, + }); + } + } + } + + /** + * Enrich entire library for a user + */ + async enrichLibrary( + userId: string, + onProgress?: (progress: { + current: number; + total: number; + item: string; + }) => void + ): Promise { + const settings = await this.getSettings(userId); + if (!settings.enabled) { + throw new Error("Enrichment is not enabled for this user"); + } + + const result: EnrichmentResult = { + success: true, + itemsProcessed: 0, + itemsEnriched: 0, + itemsFailed: 0, + errors: [], + }; + + // Get all artists with their albums + const artists = await prisma.artist.findMany({ + where: { + albums: { + some: {}, // Only artists with albums + }, + }, + select: { + id: true, + name: true, + albums: { + select: { id: true, title: true }, + }, + }, + }); + + console.log(`Starting enrichment for ${artists.length} artists...`); + + for (const artist of artists) { + try { + result.itemsProcessed++; + onProgress?.({ + current: result.itemsProcessed, + total: + artists.length + + artists.reduce((sum, a) => sum + a.albums.length, 0), + item: `${artist.name}`, + }); + + // Enrich artist + const artistEnrichmentData = await this.enrichArtist( + artist.id, + settings + ); + if ( + artistEnrichmentData && + artistEnrichmentData.confidence > 0.3 + ) { + await this.applyArtistEnrichment( + artist.id, + artistEnrichmentData + ); + result.itemsEnriched++; + } + + // Enrich all albums for this artist + for (const album of artist.albums) { + try { + result.itemsProcessed++; + onProgress?.({ + current: result.itemsProcessed, + total: + artists.length + + artists.reduce( + (sum, a) => sum + a.albums.length, + 0 + ), + item: `${artist.name} - ${album.title}`, + }); + + const albumEnrichmentData = await this.enrichAlbum( + album.id, + settings + ); + if ( + albumEnrichmentData && + albumEnrichmentData.confidence > 0.3 + ) { + await this.applyAlbumEnrichment( + album.id, + albumEnrichmentData + ); + result.itemsEnriched++; + } + + // Rate limiting between albums + await new Promise((resolve) => + setTimeout(resolve, 500) + ); + } catch (error: any) { + result.itemsFailed++; + result.errors.push({ + item: `${artist.name} - ${album.title}`, + error: error.message, + }); + console.error( + ` ✗ Failed to enrich ${artist.name} - ${album.title}:`, + error + ); + } + } + + // Rate limiting between artists + await new Promise((resolve) => setTimeout(resolve, 1000)); + } catch (error: any) { + result.itemsFailed++; + result.errors.push({ + item: artist.name, + error: error.message, + }); + console.error(` ✗ Failed to enrich ${artist.name}:`, error); + } + } + + console.log( + `Enrichment complete: ${result.itemsEnriched}/${result.itemsProcessed} items enriched` + ); + + return result; + } +} + +export const enrichmentService = new EnrichmentService(); diff --git a/backend/src/services/fanart.ts b/backend/src/services/fanart.ts new file mode 100644 index 0000000..e57fb74 --- /dev/null +++ b/backend/src/services/fanart.ts @@ -0,0 +1,214 @@ +import axios, { AxiosInstance } from "axios"; +import { redisClient } from "../utils/redis"; +import { getSystemSettings } from "../utils/systemSettings"; + +/** + * Fanart.tv API Service + * + * Provides high-quality artist images, album covers, and backgrounds + * API Docs: https://fanart.tv/api-docs/music-api/ + * + * Free tier: 2 requests/second + * API key: Get one at https://fanart.tv/get-an-api-key/ + */ +class FanartService { + private client: AxiosInstance; + private apiKey: string | null = null; + private initialized: boolean = false; + private noKeyWarningShown: boolean = false; + + constructor() { + this.client = axios.create({ + baseURL: "https://webservice.fanart.tv/v3", + timeout: 10000, + headers: { + "User-Agent": "Lidify/1.0", + }, + }); + } + + /** + * Ensure service is initialized with API key from database or .env + */ + private async ensureInitialized() { + if (this.initialized) return; + + try { + // Try to get from database first + const settings = await getSystemSettings(); + if (settings?.fanartEnabled && settings?.fanartApiKey) { + this.apiKey = settings.fanartApiKey; + console.log("Fanart.tv configured from database"); + this.initialized = true; + return; + } + } catch (error) { + // Silently continue to check .env + } + + // Fallback to .env + if (process.env.FANART_API_KEY) { + this.apiKey = process.env.FANART_API_KEY; + console.log("Fanart.tv configured from .env"); + } + // Note: Not logging "not configured" here - it's optional and logs are spammy + this.initialized = true; + } + + /** + * Get artist images (background, thumbnail, logo) + * Returns the highest quality artist image available + */ + async getArtistImage(mbid: string): Promise { + await this.ensureInitialized(); + + // Early exit if no API key - don't log every time (reduces log spam) + if (!this.apiKey) { + return null; + } + + // Check cache first + const cacheKey = `fanart:artist:${mbid}`; + try { + if (redisClient.isOpen) { + const cached = await redisClient.get(cacheKey); + if (cached) { + console.log(` Fanart.tv: Using cached image`); + return cached; + } + } + } catch (error) { + // Redis errors are non-critical + } + + try { + console.log(` Fetching from Fanart.tv...`); + const response = await this.client.get(`/music/${mbid}`, { + params: { api_key: this.apiKey }, + }); + + const data = response.data; + + // Priority: artistbackground > artistthumb > hdmusiclogo + let imageUrl: string | null = null; + + if (data.artistbackground && data.artistbackground.length > 0) { + let rawUrl = data.artistbackground[0].url; + + // If it's just a filename, construct the full URL + if (rawUrl && !rawUrl.startsWith("http")) { + rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistbackground/${rawUrl}`; + console.log( + ` Fanart.tv: Constructed full URL from filename` + ); + } + + imageUrl = rawUrl; + console.log(` Fanart.tv: Found artist background`); + } else if (data.artistthumb && data.artistthumb.length > 0) { + let rawUrl = data.artistthumb[0].url; + + // If it's just a filename, construct the full URL + if (rawUrl && !rawUrl.startsWith("http")) { + rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistthumb/${rawUrl}`; + console.log( + ` Fanart.tv: Constructed full URL from filename` + ); + } + + imageUrl = rawUrl; + console.log(` Fanart.tv: Found artist thumbnail`); + } else if (data.hdmusiclogo && data.hdmusiclogo.length > 0) { + let rawUrl = data.hdmusiclogo[0].url; + + // If it's just a filename, construct the full URL + if (rawUrl && !rawUrl.startsWith("http")) { + rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/hdmusiclogo/${rawUrl}`; + console.log( + ` Fanart.tv: Constructed full URL from filename` + ); + } + + imageUrl = rawUrl; + console.log(` Fanart.tv: Found HD logo`); + } + + // Cache for 7 days + if (imageUrl && redisClient.isOpen) { + try { + await redisClient.setEx( + cacheKey, + 7 * 24 * 60 * 60, + imageUrl + ); + } catch (error) { + // Redis errors are non-critical + } + } + + return imageUrl; + } catch (error: any) { + if (error.response?.status === 404) { + console.log(`Fanart.tv: No images found`); + } else { + console.error(` Fanart.tv error:`, error.message); + } + return null; + } + } + + /** + * Get album cover art + */ + async getAlbumCover(mbid: string): Promise { + await this.ensureInitialized(); + + if (!this.apiKey) return null; + + const cacheKey = `fanart:album:${mbid}`; + try { + if (redisClient.isOpen) { + const cached = await redisClient.get(cacheKey); + if (cached) return cached; + } + } catch (error) { + // Redis errors are non-critical + } + + try { + const response = await this.client.get(`/music/albums/${mbid}`, { + params: { api_key: this.apiKey }, + }); + + const data = response.data; + let imageUrl: string | null = null; + + if (data.albums && data.albums[mbid]) { + const album = data.albums[mbid]; + if (album.albumcover && album.albumcover.length > 0) { + imageUrl = album.albumcover[0].url; + } else if (album.cdart && album.cdart.length > 0) { + imageUrl = album.cdart[0].url; + } + } + + if (imageUrl && redisClient.isOpen) { + try { + await redisClient.setEx( + cacheKey, + 7 * 24 * 60 * 60, + imageUrl + ); + } catch (error) { + // Redis errors are non-critical + } + } + + return imageUrl; + } catch (error) { + return null; + } + } +} + +export const fanartService = new FanartService(); diff --git a/backend/src/services/fileValidator.ts b/backend/src/services/fileValidator.ts new file mode 100644 index 0000000..f65fff5 --- /dev/null +++ b/backend/src/services/fileValidator.ts @@ -0,0 +1,175 @@ +import * as fs from "fs"; +import * as path from "path"; +import { prisma } from "../utils/db"; +import { config } from "../config"; +import PQueue from "p-queue"; + +export interface ValidationResult { + tracksChecked: number; + tracksRemoved: number; + tracksMissing: string[]; // IDs of missing tracks + duration: number; +} + +export class FileValidatorService { + private validationQueue = new PQueue({ concurrency: 50 }); + + /** + * Validate all tracks in the library and remove missing files + */ + async validateLibrary(): Promise { + const startTime = Date.now(); + const result: ValidationResult = { + tracksChecked: 0, + tracksRemoved: 0, + tracksMissing: [], + duration: 0, + }; + + console.log("[FileValidator] Starting library validation..."); + + // Get all tracks from the database + const tracks = await prisma.track.findMany({ + select: { + id: true, + filePath: true, + title: true, + }, + }); + + console.log( + `[FileValidator] Found ${tracks.length} tracks to validate` + ); + + // Check each track's file existence + const missingTrackIds: string[] = []; + + for (const track of tracks) { + await this.validationQueue.add(async () => { + try { + const absolutePath = path.normalize( + path.join(config.music.musicPath, track.filePath) + ); + + // Prevent path traversal attacks + if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) { + console.warn( + `[FileValidator] Path traversal attempt detected: ${track.filePath}` + ); + missingTrackIds.push(track.id); + result.tracksChecked++; + return; + } + + const exists = await this.fileExists(absolutePath); + + if (!exists) { + console.log( + `[FileValidator] Missing file: ${track.filePath} (${track.title})` + ); + missingTrackIds.push(track.id); + } + + result.tracksChecked++; + + // Log progress every 100 tracks + if (result.tracksChecked % 100 === 0) { + console.log( + `[FileValidator] Progress: ${result.tracksChecked}/${tracks.length} tracks checked, ${missingTrackIds.length} missing` + ); + } + } catch (err: any) { + console.error( + `[FileValidator] Error checking ${track.filePath}:`, + err.message + ); + } + }); + } + + await this.validationQueue.onIdle(); + + result.tracksMissing = missingTrackIds; + + // Remove missing tracks from database + if (missingTrackIds.length > 0) { + console.log( + `[FileValidator] Removing ${missingTrackIds.length} missing tracks from database...` + ); + + await prisma.track.deleteMany({ + where: { + id: { in: missingTrackIds }, + }, + }); + + result.tracksRemoved = missingTrackIds.length; + } + + result.duration = Date.now() - startTime; + + console.log( + `[FileValidator] Validation complete: ${result.tracksChecked} checked, ${result.tracksRemoved} removed (${result.duration}ms)` + ); + + return result; + } + + /** + * Check if a file exists (async) + */ + private async fileExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } + } + + /** + * Validate a single track and remove if missing + */ + async validateTrack(trackId: string): Promise { + const track = await prisma.track.findUnique({ + where: { id: trackId }, + select: { + id: true, + filePath: true, + title: true, + }, + }); + + if (!track) { + return false; + } + + const absolutePath = path.normalize( + path.join(config.music.musicPath, track.filePath) + ); + + // Prevent path traversal attacks + if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) { + console.warn( + `[FileValidator] Path traversal attempt detected: ${track.filePath}` + ); + return false; + } + + const exists = await this.fileExists(absolutePath); + + if (!exists) { + console.log( + `[FileValidator] Track file missing, removing from DB: ${track.title}` + ); + await prisma.track.delete({ + where: { id: trackId }, + }); + return false; + } + + return true; + } +} + +export const fileValidator = new FileValidatorService(); diff --git a/backend/src/services/imageProvider.ts b/backend/src/services/imageProvider.ts new file mode 100644 index 0000000..0256c44 --- /dev/null +++ b/backend/src/services/imageProvider.ts @@ -0,0 +1,421 @@ +/** + * Image Provider Service + * + * Tries multiple sources for high-quality artist/album artwork: + * 1. Deezer (most reliable, high quality) + * 2. Fanart.tv (excellent quality, requires API key) + * 3. MusicBrainz Cover Art Archive (good quality) + * 4. Last.fm (fallback, often missing) + */ + +import axios from "axios"; + +export interface ImageSearchOptions { + preferredSize?: "small" | "medium" | "large" | "extralarge" | "mega"; + timeout?: number; +} + +export interface ImageResult { + url: string; + source: "deezer" | "fanart" | "musicbrainz" | "lastfm" | "spotify"; + size?: string; +} + +export class ImageProviderService { + private readonly FANART_API_KEY = process.env.FANART_API_KEY; + private readonly DEEZER_API_URL = "https://api.deezer.com"; + private readonly FANART_API_URL = "https://webservice.fanart.tv/v3"; + + /** + * Get artist image from multiple sources with fallback chain + */ + async getArtistImage( + artistName: string, + mbid?: string, + options: ImageSearchOptions = {} + ): Promise { + const { timeout = 5000 } = options; + + console.log(`[IMAGE] Searching for artist image: ${artistName}`); + + // Try Deezer first (most reliable) + try { + const deezerImage = await this.getArtistImageFromDeezer( + artistName, + timeout + ); + if (deezerImage) { + console.log(` Found image from Deezer`); + return deezerImage; + } + } catch (error) { + console.log( + ` Deezer failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + + // Try Fanart.tv if we have API key and MBID + if (this.FANART_API_KEY && mbid) { + try { + const fanartImage = await this.getArtistImageFromFanart( + mbid, + timeout + ); + if (fanartImage) { + console.log(` Found image from Fanart.tv`); + return fanartImage; + } + } catch (error) { + console.log( + `Fanart.tv failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + } + + // Try MusicBrainz/Cover Art Archive if we have MBID + if (mbid) { + try { + const mbImage = await this.getArtistImageFromMusicBrainz( + mbid, + timeout + ); + if (mbImage) { + console.log(` Found image from MusicBrainz`); + return mbImage; + } + } catch (error) { + console.log( + `MusicBrainz failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + } + + console.log(` ✗ No artist image found from any source`); + return null; + } + + /** + * Get album cover from multiple sources with fallback chain + */ + async getAlbumCover( + artistName: string, + albumTitle: string, + rgMbid?: string, + options: ImageSearchOptions = {} + ): Promise { + const { timeout = 5000 } = options; + + console.log( + `[IMAGE] Searching for album cover: ${artistName} - ${albumTitle}` + ); + + // Try Deezer first (most reliable) + try { + const deezerCover = await this.getAlbumCoverFromDeezer( + artistName, + albumTitle, + timeout + ); + if (deezerCover) { + console.log(` Found cover from Deezer`); + return deezerCover; + } + } catch (error) { + console.log( + ` Deezer failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + + // Try MusicBrainz Cover Art Archive if we have MBID + if (rgMbid) { + try { + const mbCover = await this.getAlbumCoverFromMusicBrainz( + rgMbid, + timeout + ); + if (mbCover) { + console.log(` Found cover from MusicBrainz`); + return mbCover; + } + } catch (error) { + console.log( + `MusicBrainz failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + } + + // Try Fanart.tv if we have API key and MBID + if (this.FANART_API_KEY && rgMbid) { + try { + const fanartCover = await this.getAlbumCoverFromFanart( + rgMbid, + timeout + ); + if (fanartCover) { + console.log(` Found cover from Fanart.tv`); + return fanartCover; + } + } catch (error) { + console.log( + `Fanart.tv failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + } + + console.log(` ✗ No album cover found from any source`); + return null; + } + + /** + * Search Deezer for artist image + */ + private async getArtistImageFromDeezer( + artistName: string, + timeout: number + ): Promise { + const response = await axios.get( + `${this.DEEZER_API_URL}/search/artist`, + { + params: { q: artistName, limit: 1 }, + timeout, + } + ); + + if (response.data.data && response.data.data.length > 0) { + const artist = response.data.data[0]; + // Deezer provides: picture, picture_small, picture_medium, picture_big, picture_xl + const imageUrl = + artist.picture_xl || artist.picture_big || artist.picture; + if (imageUrl) { + return { + url: imageUrl, + source: "deezer", + size: "xl", + }; + } + } + + return null; + } + + /** + * Search Deezer for album cover + */ + private async getAlbumCoverFromDeezer( + artistName: string, + albumTitle: string, + timeout: number + ): Promise { + const response = await axios.get( + `${this.DEEZER_API_URL}/search/album`, + { + params: { + q: `artist:"${artistName}" album:"${albumTitle}"`, + limit: 5, + }, + timeout, + } + ); + + if (response.data.data && response.data.data.length > 0) { + // Try to find exact match first + let album = response.data.data.find( + (a: any) => + a.title.toLowerCase() === albumTitle.toLowerCase() && + a.artist.name.toLowerCase() === artistName.toLowerCase() + ); + + // Fall back to first result + if (!album) { + album = response.data.data[0]; + } + + // Deezer provides: cover, cover_small, cover_medium, cover_big, cover_xl + const coverUrl = album.cover_xl || album.cover_big || album.cover; + if (coverUrl) { + return { + url: coverUrl, + source: "deezer", + size: "xl", + }; + } + } + + return null; + } + + /** + * Get artist image from Fanart.tv + */ + private async getArtistImageFromFanart( + mbid: string, + timeout: number + ): Promise { + if (!this.FANART_API_KEY) { + return null; + } + + const response = await axios.get( + `${this.FANART_API_URL}/music/${mbid}`, + { + params: { api_key: this.FANART_API_KEY }, + timeout, + } + ); + + // Fanart.tv provides multiple image types, prefer artistthumb + const images = + response.data.artistthumb || + response.data.musicbanner || + response.data.hdmusiclogo; + if (images && images.length > 0) { + return { + url: images[0].url, + source: "fanart", + }; + } + + return null; + } + + /** + * Get album cover from Fanart.tv + */ + private async getAlbumCoverFromFanart( + rgMbid: string, + timeout: number + ): Promise { + if (!this.FANART_API_KEY) { + return null; + } + + const response = await axios.get( + `${this.FANART_API_URL}/music/albums/${rgMbid}`, + { + params: { api_key: this.FANART_API_KEY }, + timeout, + } + ); + + // Prefer albumcover, fall back to cdart + const covers = + response.data.albums?.[rgMbid]?.albumcover || + response.data.albums?.[rgMbid]?.cdart; + + if (covers && covers.length > 0) { + return { + url: covers[0].url, + source: "fanart", + }; + } + + return null; + } + + /** + * Get artist image from MusicBrainz (via relationships) + */ + private async getArtistImageFromMusicBrainz( + mbid: string, + timeout: number + ): Promise { + // MusicBrainz doesn't have direct artist images, but we can check for image relationships + // This is a placeholder - in practice, we'd need to parse relationships + return null; + } + + /** + * Get album cover from MusicBrainz Cover Art Archive + */ + private async getAlbumCoverFromMusicBrainz( + rgMbid: string, + timeout: number + ): Promise { + try { + const response = await axios.get( + `https://coverartarchive.org/release-group/${rgMbid}`, + { + timeout, + validateStatus: (status) => status === 200, + } + ); + + if (response.data.images && response.data.images.length > 0) { + // Find front cover + const frontCover = + response.data.images.find( + (img: any) => img.front === true + ) || response.data.images[0]; + + return { + url: frontCover.image, + source: "musicbrainz", + }; + } + } catch (error) { + // 404 is expected if no cover art exists + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null; + } + throw error; + } + + return null; + } + + /** + * Get artist image from Last.fm (fallback only - often unreliable) + */ + async getArtistImageFromLastFm( + artistName: string, + mbid?: string + ): Promise { + try { + const { lastFmService } = await import("./lastfm"); + const artistInfo = await lastFmService.getArtistInfo( + artistName, + mbid + ); + + if (artistInfo?.image) { + const megaImage = artistInfo.image.find( + (img: any) => img.size === "mega" + ); + const largeImage = artistInfo.image.find( + (img: any) => img.size === "extralarge" + ); + const image = megaImage || largeImage; + + if (image?.["#text"]) { + return { + url: image["#text"], + source: "lastfm", + size: image.size, + }; + } + } + } catch (error) { + console.log( + `Last.fm failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } + + return null; + } +} + +export const imageProviderService = new ImageProviderService(); diff --git a/backend/src/services/itunes.ts b/backend/src/services/itunes.ts new file mode 100644 index 0000000..cf7e935 --- /dev/null +++ b/backend/src/services/itunes.ts @@ -0,0 +1,339 @@ +import axios, { AxiosInstance } from "axios"; +import { redisClient } from "../utils/redis"; + +interface ItunesPodcast { + collectionId: number; + collectionName: string; + artistName: string; + artworkUrl600?: string; + artworkUrl100?: string; + feedUrl: string; + genres: string[]; + trackCount?: number; + country?: string; + primaryGenreName?: string; + contentAdvisoryRating?: string; + collectionViewUrl?: string; +} + +class ItunesService { + private client: AxiosInstance; + private lastRequestTime = 0; + private readonly RATE_LIMIT_MS = 3000; // 20 requests per minute = 3 seconds between requests + + constructor() { + this.client = axios.create({ + baseURL: "https://itunes.apple.com", + timeout: 10000, + }); + } + + private async rateLimit() { + const now = Date.now(); + const timeSinceLastRequest = now - this.lastRequestTime; + + if (timeSinceLastRequest < this.RATE_LIMIT_MS) { + const delay = this.RATE_LIMIT_MS - timeSinceLastRequest; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + this.lastRequestTime = Date.now(); + } + + private async cachedRequest( + cacheKey: string, + requestFn: () => Promise, + ttlSeconds = 604800 // 7 days default + ): Promise { + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + await this.rateLimit(); + const data = await requestFn(); + + try { + await redisClient.setEx(cacheKey, ttlSeconds, JSON.stringify(data)); + } catch (err) { + console.warn("Redis set error:", err); + } + + return data; + } + + /** + * Search for podcasts by term + */ + async searchPodcasts( + term: string, + limit = 20 + ): Promise { + const cacheKey = `itunes:search:${term}:${limit}`; + + return this.cachedRequest( + cacheKey, + async () => { + const response = await this.client.get("/search", { + params: { + term, + media: "podcast", + entity: "podcast", + limit, + }, + }); + + return response.data.results || []; + }, + 2592000 // 30 days - podcast catalog changes slowly + ); + } + + /** + * Lookup podcast by iTunes ID + */ + async getPodcastById(podcastId: number): Promise { + const cacheKey = `itunes:podcast:${podcastId}`; + + return this.cachedRequest( + cacheKey, + async () => { + const response = await this.client.get("/lookup", { + params: { + id: podcastId, + entity: "podcast", + }, + }); + + const results = response.data.results || []; + return results.length > 0 ? results[0] : null; + }, + 2592000 // 30 days + ); + } + + /** + * Extract primary keywords from podcast title/description for "similar podcasts" search + */ + extractSearchKeywords( + title: string, + description?: string, + author?: string + ): string[] { + const commonWords = new Set([ + "the", + "a", + "an", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "of", + "with", + "by", + "from", + "up", + "about", + "into", + "through", + "during", + "before", + "after", + "above", + "below", + "between", + "under", + "again", + "further", + "then", + "once", + "here", + "there", + "when", + "where", + "why", + "how", + "all", + "both", + "each", + "few", + "more", + "most", + "other", + "some", + "such", + "no", + "nor", + "not", + "only", + "own", + "same", + "so", + "than", + "too", + "very", + "can", + "will", + "just", + "should", + "now", + "podcast", + "show", + "episode", + "episodes", + ]); + + // Combine title and description + const text = [title, description || "", author || ""] + .join(" ") + .toLowerCase() + .replace(/[^\w\s]/g, " "); // Remove punctuation + + // Extract words, filter common words, and count occurrences + const words = text.split(/\s+/).filter((word) => { + return ( + word.length > 3 && + !commonWords.has(word) && + !/^\d+$/.test(word) // Remove pure numbers + ); + }); + + // Count word frequency + const wordCount = new Map(); + words.forEach((word) => { + wordCount.set(word, (wordCount.get(word) || 0) + 1); + }); + + // Sort by frequency and take top 5 + const topWords = Array.from(wordCount.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([word]) => word); + + return topWords; + } + + /** + * Get similar podcasts based on keywords extracted from title/description + * This provides a "similar podcasts" feature similar to Last.fm for music + */ + async getSimilarPodcasts( + title: string, + description?: string, + author?: string, + limit = 10 + ): Promise { + const keywords = this.extractSearchKeywords(title, description, author); + + if (keywords.length === 0) { + console.log( + "No keywords extracted for similar podcast search, falling back to title" + ); + return this.searchPodcasts(title, limit); + } + + console.log( + ` Searching for similar podcasts using keywords: ${keywords.join(", ")}` + ); + + // Search using the top keyword (most relevant) + const searchTerm = keywords[0]; + const cacheKey = `itunes:similar:${searchTerm}:${limit}`; + + return this.cachedRequest( + cacheKey, + async () => { + const results = await this.searchPodcasts(searchTerm, limit * 2); + + // Filter out the original podcast (by title similarity) + const titleLower = title.toLowerCase(); + const filtered = results.filter((podcast) => { + const podcastTitleLower = podcast.collectionName.toLowerCase(); + // Exclude if titles are very similar (likely same podcast) + return !podcastTitleLower.includes(titleLower.slice(0, 20)); + }); + + return filtered.slice(0, limit); + }, + 2592000 // 30 days + ); + } + + /** + * Get top podcasts by genre using iTunes RSS feeds + * Note: iTunes Search API doesn't support genreId filtering, but RSS feeds do + */ + async getTopPodcastsByGenre( + genreId: number, + limit = 20 + ): Promise { + console.log(`[iTunes SERVICE] getTopPodcastsByGenre called with genre=${genreId}, limit=${limit}`); + const cacheKey = `itunes:genre:${genreId}:${limit}`; + console.log(`[iTunes SERVICE] Cache key: ${cacheKey}`); + + const result = await this.cachedRequest( + cacheKey, + async () => { + try { + console.log(`[iTunes] Fetching genre ${genreId} from RSS feed...`); + + // Use iTunes RSS feed for top podcasts by genre + const response = await this.client.get( + `/us/rss/toppodcasts/genre=${genreId}/limit=${limit}/json` + ); + + console.log(`[iTunes] Response status: ${response.status}`); + console.log(`[iTunes] Has feed data: ${!!response.data?.feed}`); + console.log(`[iTunes] Entries count: ${response.data?.feed?.entry?.length || 0}`); + + const entries = response.data?.feed?.entry || []; + + // If only one entry, it might not be an array + const entriesArray = Array.isArray(entries) ? entries : [entries]; + + console.log(`[iTunes] Processing ${entriesArray.length} entries`); + + // Convert RSS feed format to our podcast format + const podcasts = entriesArray.map((entry: any) => { + const podcast = { + collectionId: parseInt(entry.id?.attributes?.["im:id"] || "0", 10), + collectionName: entry["im:name"]?.label || entry.title?.label?.split(" - ")[0] || "Unknown", + artistName: entry["im:artist"]?.label || entry.title?.label?.split(" - ")[1] || "Unknown", + artworkUrl600: entry["im:image"]?.find((img: any) => img.attributes?.height === "170")?.label, + artworkUrl100: entry["im:image"]?.find((img: any) => img.attributes?.height === "60")?.label, + feedUrl: "", // RSS feed doesn't include feed URL + genres: entry.category ? [entry.category.attributes?.label] : [], + trackCount: 0, + primaryGenreName: entry.category?.attributes?.label, + collectionViewUrl: entry.link?.attributes?.href, + }; + console.log(`[iTunes] Mapped podcast: ${podcast.collectionName} (ID: ${podcast.collectionId})`); + return podcast; + }).filter((p: any) => p.collectionId > 0); // Filter out invalid entries + + console.log(`[iTunes] Returning ${podcasts.length} valid podcasts`); + return podcasts; + } catch (error) { + console.error(`[iTunes] ERROR in requestFn:`, error); + return []; + } + }, + 2592000 // 30 days + ); + + console.log(`[iTunes SERVICE] cachedRequest returned ${result.length} podcasts`); + return result; + } +} + + + +export const itunesService = new ItunesService(); diff --git a/backend/src/services/lastfm.ts b/backend/src/services/lastfm.ts new file mode 100644 index 0000000..4167a20 --- /dev/null +++ b/backend/src/services/lastfm.ts @@ -0,0 +1,947 @@ +import axios, { AxiosInstance } from "axios"; +import * as fuzz from "fuzzball"; +import { config } from "../config"; +import { redisClient } from "../utils/redis"; +import { getSystemSettings } from "../utils/systemSettings"; +import { fanartService } from "./fanart"; +import { deezerService } from "./deezer"; +import { rateLimiter } from "./rateLimiter"; + +interface SimilarArtist { + name: string; + mbid?: string; + match: number; // 0-1 similarity score + url: string; +} + +class LastFmService { + private client: AxiosInstance; + private apiKey: string; + private initialized = false; + + constructor() { + // Initial value from .env (for backwards compatibility) + this.apiKey = config.lastfm.apiKey; + this.client = axios.create({ + baseURL: "https://ws.audioscrobbler.com/2.0/", + timeout: 10000, + }); + } + + private async ensureInitialized() { + if (this.initialized) return; + + // Priority: 1) User settings from DB, 2) env var, 3) default app key + try { + const { getSystemSettings } = await import( + "../utils/systemSettings" + ); + const settings = await getSystemSettings(); + if (settings?.lastfmApiKey) { + this.apiKey = settings.lastfmApiKey; + console.log("Last.fm configured from user settings"); + } else if (this.apiKey) { + console.log("Last.fm configured (default app key)"); + } + } catch (err) { + // DB not ready yet, use default/env key + if (this.apiKey) { + console.log("Last.fm configured (default app key)"); + } + } + + if (!this.apiKey) { + console.warn("Last.fm API key not available"); + } + + this.initialized = true; + } + + private async request(params: Record) { + await this.ensureInitialized(); + const response = await rateLimiter.execute("lastfm", () => + this.client.get("/", { params }) + ); + return response.data; + } + + async getSimilarArtists( + artistMbid: string, + artistName: string, + limit = 30 + ): Promise { + const cacheKey = `lastfm:similar:${artistMbid}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const data = await this.request({ + method: "artist.getSimilar", + mbid: artistMbid, + api_key: this.apiKey, + format: "json", + limit, + }); + + const similar = data.similarartists?.artist || []; + + const results: SimilarArtist[] = similar.map((artist: any) => ({ + name: artist.name, + mbid: artist.mbid || undefined, + match: parseFloat(artist.match) || 0, + url: artist.url, + })); + + // Cache for 7 days + try { + await redisClient.setEx( + cacheKey, + 604800, + JSON.stringify(results) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return results; + } catch (error: any) { + // If MBID lookup fails, try by name + if ( + error.response?.status === 404 || + error.response?.data?.error === 6 + ) { + console.log( + `Artist MBID not found on Last.fm, trying name search: ${artistName}` + ); + return this.getSimilarArtistsByName(artistName, limit); + } + + console.error(`Last.fm error for ${artistName}:`, error); + return []; + } + } + + private async getSimilarArtistsByName( + artistName: string, + limit = 30 + ): Promise { + const cacheKey = `lastfm:similar:name:${artistName}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const data = await this.request({ + method: "artist.getSimilar", + artist: artistName, + api_key: this.apiKey, + format: "json", + limit, + }); + + const similar = data.similarartists?.artist || []; + + const results: SimilarArtist[] = similar.map((artist: any) => ({ + name: artist.name, + mbid: artist.mbid || undefined, + match: parseFloat(artist.match) || 0, + url: artist.url, + })); + + // Cache for 7 days + try { + await redisClient.setEx( + cacheKey, + 604800, + JSON.stringify(results) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return results; + } catch (error) { + console.error(`Last.fm error for ${artistName}:`, error); + return []; + } + } + + async getAlbumInfo(artistName: string, albumName: string) { + const cacheKey = `lastfm:album:${artistName}:${albumName}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const data = await this.request({ + method: "album.getInfo", + artist: artistName, + album: albumName, + api_key: this.apiKey, + format: "json", + }); + + const album = data.album; + + // Cache for 30 days + try { + await redisClient.setEx( + cacheKey, + 2592000, + JSON.stringify(album) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return album; + } catch (error) { + console.error(`Last.fm album info error for ${albumName}:`, error); + return null; + } + } + + async getTopAlbumsByTag(tag: string, limit = 20) { + const cacheKey = `lastfm:tag:albums:${tag}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const data = await this.request({ + method: "tag.getTopAlbums", + tag, + api_key: this.apiKey, + format: "json", + limit, + }); + + const albums = data.albums?.album || []; + + // Cache for 7 days + try { + await redisClient.setEx( + cacheKey, + 604800, + JSON.stringify(albums) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return albums; + } catch (error) { + console.error(`Last.fm tag albums error for ${tag}:`, error); + return []; + } + } + + async getSimilarTracks(artistName: string, trackName: string, limit = 20) { + const cacheKey = `lastfm:similar:track:${artistName}:${trackName}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const data = await this.request({ + method: "track.getSimilar", + artist: artistName, + track: trackName, + api_key: this.apiKey, + format: "json", + limit, + }); + + const tracks = data.similartracks?.track || []; + + // Cache for 7 days + try { + await redisClient.setEx( + cacheKey, + 604800, + JSON.stringify(tracks) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return tracks; + } catch (error) { + console.error( + `Last.fm similar tracks error for ${trackName}:`, + error + ); + return []; + } + } + + async getArtistTopTracks( + artistMbid: string, + artistName: string, + limit = 10 + ) { + const cacheKey = `lastfm:toptracks:${artistMbid || artistName}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const params: any = { + method: "artist.getTopTracks", + api_key: this.apiKey, + format: "json", + limit, + }; + + if (artistMbid) { + params.mbid = artistMbid; + } else { + params.artist = artistName; + } + + const data = await this.request(params); + + const tracks = data.toptracks?.track || []; + + // Cache for 7 days + try { + await redisClient.setEx( + cacheKey, + 604800, + JSON.stringify(tracks) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return tracks; + } catch (error) { + console.error(`Last.fm top tracks error for ${artistName}:`, error); + return []; + } + } + + async getArtistTopAlbums( + artistMbid: string, + artistName: string, + limit = 10 + ) { + const cacheKey = `lastfm:topalbums:${artistMbid || artistName}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const params: any = { + method: "artist.getTopAlbums", + api_key: this.apiKey, + format: "json", + limit, + }; + + if (artistMbid) { + params.mbid = artistMbid; + } else { + params.artist = artistName; + } + + const data = await this.request(params); + + const albums = data.topalbums?.album || []; + + // Cache for 7 days + try { + await redisClient.setEx( + cacheKey, + 604800, + JSON.stringify(albums) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return albums; + } catch (error) { + console.error(`Last.fm top albums error for ${artistName}:`, error); + return []; + } + } + + /** + * Get detailed artist info including real images + */ + async getArtistInfo(artistName: string, mbid?: string) { + try { + const params: any = { + method: "artist.getinfo", + api_key: this.apiKey, + format: "json", + }; + + if (mbid) { + params.mbid = mbid; + } else { + params.artist = artistName; + } + + const data = await this.request(params); + return data.artist; + } catch (error) { + console.error( + `Last.fm artist info error for ${artistName}:`, + error + ); + return null; + } + } + + /** + * Extract the best available image from Last.fm image array + */ + public getBestImage(imageArray: any[]): string | null { + if (!imageArray || !Array.isArray(imageArray)) { + return null; + } + + // Try extralarge first, then large, then medium, then small + const image = + imageArray.find((img: any) => img.size === "extralarge")?.[ + "#text" + ] || + imageArray.find((img: any) => img.size === "large")?.["#text"] || + imageArray.find((img: any) => img.size === "medium")?.["#text"] || + imageArray.find((img: any) => img.size === "small")?.["#text"]; + + // Filter out empty/placeholder images + if ( + !image || + image === "" || + image.includes("2a96cbd8b46e442fc41c2b86b821562f") + ) { + return null; + } + + return image; + } + + private isInvalidArtistName(name?: string | null) { + if (!name) return true; + const normalized = name.trim().toLowerCase(); + return ( + normalized.length === 0 || + normalized === "unknown" || + normalized === "various artists" + ); + } + + private normalizeName(name: string | undefined | null) { + return (name || "").trim().toLowerCase(); + } + + private normalizeKey(name: string | undefined | null) { + return this.normalizeName(name) + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]/g, ""); + } + + private getArtistKey(artist: any) { + return ( + artist.mbid || this.normalizeKey(artist.name) || artist.url || "" + ); + } + + private isDuplicateArtist(existing: any[], candidate: any) { + const candidateKey = this.getArtistKey(candidate); + if (!candidateKey) { + return true; + } + + for (const entry of existing) { + const entryKey = this.getArtistKey(entry); + if (entryKey && entryKey === candidateKey) { + return true; + } + + const nameSimilarity = fuzz.ratio( + this.normalizeName(entry.name), + this.normalizeName(candidate.name) + ); + + if (nameSimilarity >= 95) { + return true; + } + } + + return false; + } + + private isStandaloneSingle(albumName: string, trackName: string) { + const albumLower = albumName.toLowerCase(); + const trackLower = trackName.toLowerCase(); + + return ( + albumLower === trackLower || + albumLower === `${trackLower} - single` || + albumLower.endsWith(" - single") || + albumLower.endsWith(" (single)") + ); + } + + private async buildArtistSearchResult(artist: any, enrich: boolean) { + const baseResult = { + type: "music", + id: artist.mbid || artist.name, + name: artist.name, + listeners: parseInt(artist.listeners || "0", 10), + url: artist.url, + image: this.getBestImage(artist.image), + mbid: artist.mbid, + bio: null, + tags: [] as string[], + }; + + if (!enrich) { + return baseResult; + } + + const [info, fanartImage, deezerImage] = await Promise.all([ + this.getArtistInfo(artist.name, artist.mbid), + artist.mbid + ? fanartService + .getArtistImage(artist.mbid) + .catch(() => null as string | null) + : Promise.resolve(null), + deezerService + .getArtistImage(artist.name) + .catch(() => null as string | null), + ]); + + const resolvedImage = + fanartImage || + deezerImage || + (info ? this.getBestImage(info.image) : null) || + baseResult.image; + + return { + ...baseResult, + image: resolvedImage, + bio: info?.bio?.summary || info?.bio?.content || null, + tags: info?.tags?.tag?.map((t: any) => t.name) || [], + }; + } + + private async buildTrackSearchResult(track: any, enrich: boolean) { + if (this.isInvalidArtistName(track.artist)) { + return null; + } + + const baseResult = { + type: "track", + id: track.mbid || `${track.artist}-${track.name}`, + name: track.name, + artist: track.artist, + album: track.album || null, + listeners: parseInt(track.listeners || "0", 10), + url: track.url, + image: this.getBestImage(track.image), + mbid: track.mbid, + }; + + if (!enrich) { + return baseResult; + } + + const trackInfo = await this.getTrackInfo(track.artist, track.name); + + let albumName = trackInfo?.album?.title || baseResult.album; + let albumArt = + this.getBestImage(trackInfo?.album?.image) || baseResult.image; + + if (albumName && this.isStandaloneSingle(albumName, track.name)) { + return null; + } + + if (!albumArt) { + albumArt = await deezerService + .getArtistImage(track.artist) + .catch(() => null as string | null); + } + + return { + ...baseResult, + album: albumName, + image: albumArt, + }; + } + + /** + * Search for artists on Last.fm and fetch their detailed info with images + */ + async searchArtists(query: string, limit = 20) { + try { + const data = await this.request({ + method: "artist.search", + artist: query, + api_key: this.apiKey, + format: "json", + limit, + }); + + const artists = data.results?.artistmatches?.artist || []; + + console.log( + `\n [LAST.FM SEARCH] Found ${artists.length} artists (before filtering)` + ); + + const queryLower = query.toLowerCase().trim(); + const words = queryLower.split(/\s+/).filter(Boolean); + const minWordMatches = + words.length <= 2 + ? words.length + : Math.max(1, words.length - 1); + + const escapeRegex = (text: string) => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const wordMatchers = words.map((word) => { + if (word.length <= 2) { + return (candidate: string) => candidate.includes(word); + } + const regex = new RegExp(`\\b${escapeRegex(word)}\\b`); + return (candidate: string) => regex.test(candidate); + }); + + const scoredArtists = artists + .map((artist: any) => { + const normalizedName = this.normalizeName(artist.name); + const similarity = fuzz.token_set_ratio( + queryLower, + normalizedName + ); + const listeners = parseInt(artist.listeners || "0", 10); + const hasMbid = Boolean(artist.mbid); + const wordMatches = wordMatchers.filter((matcher) => + matcher(normalizedName) + ).length; + + return { + artist, + similarity, + listeners, + hasMbid, + wordMatches, + }; + }) + .filter(({ similarity, wordMatches }) => { + if (!queryLower) return true; + return similarity >= 50 || wordMatches >= minWordMatches; + }) + .sort((a, b) => { + return ( + Number(b.hasMbid) - Number(a.hasMbid) || + b.wordMatches - a.wordMatches || + b.listeners - a.listeners || + b.similarity - a.similarity + ); + }); + + const uniqueArtists: any[] = []; + + for (const entry of scoredArtists) { + const artist = entry.artist; + if (this.isDuplicateArtist(uniqueArtists, artist)) { + continue; + } + + uniqueArtists.push(artist); + } + + if (uniqueArtists.length > 0 && uniqueArtists.length < limit) { + const primaryArtist = uniqueArtists[0]; + try { + const fallbackSimilar = await this.getSimilarArtists( + primaryArtist.mbid || "", + primaryArtist.name, + limit * 2 + ); + + for (const similar of fallbackSimilar) { + if (uniqueArtists.length >= limit) { + break; + } + + const candidate = { + name: similar.name, + mbid: similar.mbid, + listeners: 0, + url: similar.url, + image: [], + }; + + if (this.isDuplicateArtist(uniqueArtists, candidate)) { + continue; + } + + uniqueArtists.push(candidate); + } + } catch (error) { + console.warn( + "[LAST.FM SEARCH] Similar artist fallback failed:", + error + ); + } + } + + const limitedArtists = uniqueArtists.slice(0, limit); + + console.log( + ` → Filtered to ${limitedArtists.length} relevant matches (limit: ${limit})` + ); + + const enrichmentCount = Math.min(5, limitedArtists.length); + const [enriched, fast] = await Promise.all([ + Promise.all( + limitedArtists + .slice(0, enrichmentCount) + .map((artist: any) => + this.buildArtistSearchResult(artist, true) + ) + ), + Promise.all( + limitedArtists + .slice(enrichmentCount) + .map((artist: any) => + this.buildArtistSearchResult(artist, false) + ) + ), + ]); + + return [...enriched, ...fast].filter(Boolean); + } catch (error) { + console.error("Last.fm artist search error:", error); + return []; + } + } + + /** + * Search for tracks on Last.fm + */ + async searchTracks(query: string, limit = 20) { + try { + const data = await this.request({ + method: "track.search", + track: query, + api_key: this.apiKey, + format: "json", + limit, + }); + + const tracks = data.results?.trackmatches?.track || []; + + console.log( + `\n [LAST.FM TRACK SEARCH] Found ${tracks.length} tracks` + ); + + const validTracks = tracks.filter( + (track: any) => !this.isInvalidArtistName(track.artist) + ); + const limitedTracks = validTracks.slice(0, limit); + + const enrichmentCount = Math.min(8, limitedTracks.length); + + const [enriched, fast] = await Promise.all([ + Promise.all( + limitedTracks + .slice(0, enrichmentCount) + .map((track: any) => + this.buildTrackSearchResult(track, true) + ) + ), + Promise.all( + limitedTracks + .slice(enrichmentCount) + .map((track: any) => + this.buildTrackSearchResult(track, false) + ) + ), + ]); + + return [...enriched, ...fast].filter(Boolean); + } catch (error) { + console.error("Last.fm track search error:", error); + return []; + } + } + + /** + * Get detailed track info including album + */ + async getTrackInfo(artistName: string, trackName: string) { + try { + const data = await this.request({ + method: "track.getInfo", + artist: artistName, + track: trackName, + api_key: this.apiKey, + format: "json", + }); + + return data.track; + } catch (error) { + // Don't log errors for track info (many tracks don't have full info) + return null; + } + } + + /** + * Get popular artists from Last.fm charts + */ + async getTopChartArtists(limit = 20) { + await this.ensureInitialized(); + + // Return empty if no API key configured + if (!this.apiKey) { + console.warn( + "Last.fm: Cannot fetch chart artists - no API key configured" + ); + return []; + } + + const cacheKey = `lastfm:chart:artists:${limit}`; + + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + try { + const data = await this.request({ + method: "chart.getTopArtists", + api_key: this.apiKey, + format: "json", + limit, + }); + + const artists = data.artists?.artist || []; + + // Get detailed info for each artist with images + const detailedArtists = await Promise.all( + artists.map(async (artist: any) => { + // Try to get image from Fanart.tv using MBID + let image = null; + if (artist.mbid) { + try { + image = await fanartService.getArtistImage( + artist.mbid + ); + } catch (error) { + // Silently fail + } + } + + // Fallback to Deezer (most reliable) + if (!image) { + try { + const deezerImage = + await deezerService.getArtistImage(artist.name); + if (deezerImage) { + image = deezerImage; + } + } catch (error) { + // Silently fail + } + } + + // Last fallback to Last.fm images (but filter placeholders) + if (!image) { + const lastFmImage = this.getBestImage(artist.image); + if ( + lastFmImage && + !lastFmImage.includes( + "2a96cbd8b46e442fc41c2b86b821562f" + ) + ) { + image = lastFmImage; + } + } + + return { + type: "music", + id: artist.mbid || artist.name, + name: artist.name, + listeners: parseInt(artist.listeners || "0"), + playCount: parseInt(artist.playcount || "0"), + url: artist.url, + image, + mbid: artist.mbid, + }; + }) + ); + + // Cache for 6 hours (charts update frequently) + try { + await redisClient.setEx( + cacheKey, + 21600, + JSON.stringify(detailedArtists) + ); + } catch (err) { + console.warn("Redis set error:", err); + } + + return detailedArtists; + } catch (error) { + console.error("Last.fm chart artists error:", error); + return []; + } + } +} + +export const lastFmService = new LastFmService(); diff --git a/backend/src/services/lidarr.ts b/backend/src/services/lidarr.ts new file mode 100644 index 0000000..ce9334f --- /dev/null +++ b/backend/src/services/lidarr.ts @@ -0,0 +1,2057 @@ +import axios, { AxiosInstance } from "axios"; +import { config } from "../config"; +import { getSystemSettings } from "../utils/systemSettings"; + +interface LidarrArtist { + id: number; + artistName: string; + foreignArtistId: string; // MusicBrainz ID + monitored: boolean; + tags?: number[]; // Tag IDs +} + +interface LidarrTag { + id: number; + label: string; +} + +// Discovery tag label - used to identify discovery artists in Lidarr +const DISCOVERY_TAG_LABEL = "lidify-discovery"; + +interface LidarrAlbum { + id: number; + title: string; + foreignAlbumId: string; // MusicBrainz release group ID + artistId: number; + monitored: boolean; + artist?: { + foreignArtistId: string; // MusicBrainz artist ID + artistName: string; + }; +} + +class LidarrService { + private client: AxiosInstance | null = null; + private enabled: boolean; + private initialized: boolean = false; + + constructor() { + // Initial check from .env (for backwards compatibility) + this.enabled = config.lidarr?.enabled || false; + + if (this.enabled && config.lidarr) { + this.client = axios.create({ + baseURL: config.lidarr.url, + timeout: 30000, + headers: { + "X-Api-Key": config.lidarr.apiKey, + }, + }); + } + } + + private async ensureInitialized() { + if (this.initialized) return; + + try { + // Try to load from database + const settings = await getSystemSettings(); + + if (settings && settings.lidarrEnabled) { + const url = settings.lidarrUrl || config.lidarr?.url; + const apiKey = settings.lidarrApiKey || config.lidarr?.apiKey; + + if (url && apiKey) { + console.log("Lidarr configured from database"); + this.client = axios.create({ + baseURL: url, + timeout: 30000, + headers: { + "X-Api-Key": apiKey, + }, + }); + this.enabled = true; + } else { + console.warn(" Lidarr enabled but missing URL or API key"); + this.enabled = false; + } + } else if (config.lidarr) { + // Fallback to .env + console.log("Lidarr configured from .env"); + this.enabled = true; + } else { + console.log(" Lidarr not enabled"); + this.enabled = false; + } + } catch (error) { + console.error("Failed to load Lidarr settings:", error); + // Keep .env config if database fails + } + + this.initialized = true; + } + + async isEnabled(): Promise { + await this.ensureInitialized(); + return this.enabled; + } + + /** + * Ensure the root folder exists in Lidarr, fallback to first available if not + */ + private async ensureRootFolderExists( + requestedPath: string + ): Promise { + if (!this.client) { + return requestedPath; + } + + try { + // Get all root folders from Lidarr + const response = await this.client.get("/api/v1/rootfolder"); + const rootFolders = response.data; + + if (rootFolders.length === 0) { + console.warn(" No root folders configured in Lidarr!"); + return requestedPath; + } + + // Check if requested path exists + const exists = rootFolders.find( + (folder: any) => folder.path === requestedPath + ); + + if (exists) { + return requestedPath; + } + + // Fallback to first available root folder + const fallback = rootFolders[0].path; + console.log(` Root folder "${requestedPath}" not found in Lidarr`); + console.log(` Using fallback: "${fallback}"`); + return fallback; + } catch (error) { + console.error("Error checking root folders:", error); + return requestedPath; // Return requested path and let Lidarr error if needed + } + } + + async searchArtist( + artistName: string, + mbid?: string + ): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + const response = await this.client.get("/api/v1/artist/lookup", { + params: { + term: mbid ? `lidarr:${mbid}` : artistName, + }, + }); + + // If Lidarr's lookup returned results, use them + if (response.data && response.data.length > 0) { + return response.data; + } + + // FALLBACK: Lidarr's metadata server may be having issues + // If we have an MBID, create a minimal artist object from our own MusicBrainz data + if (mbid) { + console.log(` [FALLBACK] Lidarr lookup failed, using direct MusicBrainz data for MBID: ${mbid}`); + + try { + // Import MusicBrainz service dynamically to avoid circular deps + const { musicBrainzService } = await import("./musicbrainz"); + + // Get artist info from MusicBrainz directly + const mbArtists = await musicBrainzService.searchArtist(artistName, 5); + const mbArtist = mbArtists?.find(a => a.id === mbid) || mbArtists?.[0]; + + if (mbArtist) { + // Create a minimal Lidarr-compatible artist object + const fallbackArtist: LidarrArtist = { + id: 0, // Will be assigned when added + artistName: mbArtist.name || artistName, + foreignArtistId: mbid, + artistType: mbArtist.type || "Person", + monitored: false, + qualityProfileId: 1, + metadataProfileId: 1, + rootFolderPath: "/music", + tags: [], + statistics: { albumCount: 0 } + }; + + console.log(` [FALLBACK] Created artist from MusicBrainz: ${fallbackArtist.artistName}`); + return [fallbackArtist]; + } + } catch (mbError: any) { + console.error(` [FALLBACK] MusicBrainz lookup also failed:`, mbError.message); + } + } + + return response.data || []; + } catch (error) { + console.error("Lidarr artist search error:", error); + + // FALLBACK on error too + if (mbid) { + console.log(` [FALLBACK] Lidarr error, trying MusicBrainz for MBID: ${mbid}`); + try { + const { musicBrainzService } = await import("./musicbrainz"); + const mbArtists = await musicBrainzService.searchArtist(artistName, 5); + const mbArtist = mbArtists?.find(a => a.id === mbid) || mbArtists?.[0]; + + if (mbArtist) { + const fallbackArtist: LidarrArtist = { + id: 0, + artistName: mbArtist.name || artistName, + foreignArtistId: mbid, + artistType: mbArtist.type || "Person", + monitored: false, + qualityProfileId: 1, + metadataProfileId: 1, + rootFolderPath: "/music", + tags: [], + statistics: { albumCount: 0 } + }; + console.log(` [FALLBACK] Created artist from MusicBrainz: ${fallbackArtist.artistName}`); + return [fallbackArtist]; + } + } catch (mbError: any) { + console.error(` [FALLBACK] MusicBrainz also failed:`, mbError.message); + } + } + + return []; + } + } + + async addArtist( + mbid: string, + artistName: string, + rootFolderPath: string = "/music", + searchForMissingAlbums: boolean = true, + monitorAllAlbums: boolean = true, + isDiscovery: boolean = false + ): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + // Get discovery tag ID if this is a discovery add + let discoveryTagId: number | null = null; + if (isDiscovery) { + discoveryTagId = await this.getOrCreateDiscoveryTag(); + if (discoveryTagId) { + console.log(`[LIDARR] Will apply discovery tag (ID: ${discoveryTagId}) to artist`); + } + } + + try { + // Ensure root folder exists, fallback to default if not + const validRootFolder = await this.ensureRootFolderExists( + rootFolderPath + ); + + console.log( + ` Searching Lidarr for artist: "${artistName}"${ + mbid ? ` (MBID: ${mbid})` : " (no MBID - using name search)" + }` + ); + console.log(` Root folder: ${validRootFolder}`); + + // Search for artist (by MBID if available, otherwise by name) + const searchResults = await this.searchArtist(artistName, mbid); + + if (searchResults.length === 0) { + console.error(` Artist not found in Lidarr: ${artistName}`); + return null; + } + + console.log(` Found ${searchResults.length} results from Lidarr`); + + let artistData: LidarrArtist; + + if (mbid) { + // STRICT MBID FILTERING - Only use exact MBID match + const exactMatch = searchResults.find( + (artist) => artist.foreignArtistId === mbid + ); + + if (!exactMatch) { + console.error( + ` No exact MBID match found for: ${artistName} (${mbid})` + ); + console.log( + " Available results:", + searchResults.map((a) => ({ + name: a.artistName, + mbid: a.foreignArtistId, + type: a.artistType, + })) + ); + return null; + } + + // ADDITIONAL CHECK: If exact match is a "Group" with 0 albums, + // look for a better match with same name but different type + if ( + exactMatch.artistType === "Group" && + (exactMatch.statistics?.albumCount || 0) === 0 + ) { + console.log( + ` Exact MBID match is a Group with 0 albums - checking for better match...` + ); + + // Look for same artist name but different type with albums + const betterMatch = searchResults.find( + (artist) => + artist.artistName.toLowerCase() === + exactMatch.artistName.toLowerCase() && + artist.foreignArtistId !== mbid && + (artist.statistics?.albumCount || 0) > 0 && + (artist.artistType === "Person" || + artist.artistType === "Artist") + ); + + if (betterMatch) { + console.log( + ` Found better match: "${ + betterMatch.artistName + }" (Type: ${betterMatch.artistType}, Albums: ${ + betterMatch.statistics?.albumCount || 0 + })` + ); + artistData = betterMatch; + } else { + console.log( + ` No better match found, using Group entry` + ); + artistData = exactMatch; + } + } else { + console.log( + `Exact match found: "${exactMatch.artistName}" (Type: ${ + exactMatch.artistType + }, Albums: ${exactMatch.statistics?.albumCount || 0})` + ); + artistData = exactMatch; + } + } else { + // FALLBACK: No MBID - Use smart filtering for best match + console.log(" No MBID available - using smart selection..."); + + // Filter and score results + const scoredResults = searchResults.map((artist) => { + let score = 0; + + // Prefer "Person" or "Group" types for actual artists + const type = (artist.artistType || "").toLowerCase(); + if (type === "person") score += 1000; + else if (type === "group") score += 900; + else if (type === "artist") score += 800; + + // Album count (more albums = more likely correct) + const albumCount = artist.statistics?.albumCount || 0; + score += albumCount * 10; + + // Exact name match bonus (case-insensitive) + const artistNameNormalized = (artist.artistName || "") + .toLowerCase() + .trim(); + const searchNameNormalized = artistName + .toLowerCase() + .trim(); + + if (artistNameNormalized === searchNameNormalized) { + score += 500; + } else if ( + artistNameNormalized.includes(searchNameNormalized) || + searchNameNormalized.includes(artistNameNormalized) + ) { + score += 250; // Partial match + } + + // Popularity + if (artist.ratings?.votes && artist.ratings?.votes > 0) { + score += Math.min(artist.ratings.votes / 10, 100); + } + + // Penalize "Various Artists" entries + if ( + artistNameNormalized.includes("various") || + artistNameNormalized.includes("compilation") + ) { + score -= 1000; + } + + return { artist, score }; + }); + + // Sort by score + scoredResults.sort((a, b) => b.score - a.score); + + // Log candidates for debugging + console.log(" Candidates:"); + scoredResults.slice(0, 3).forEach((item, i) => { + console.log( + ` ${i + 1}. "${item.artist.artistName}" - Type: ${ + item.artist.artistType || "Unknown" + } - Albums: ${ + item.artist.statistics?.albumCount || 0 + } - Score: ${item.score}${i === 0 ? " ← SELECTED" : ""}` + ); + }); + + artistData = scoredResults[0].artist; + } + + // Check if already exists + const existingArtists = await this.client.get("/api/v1/artist"); + const exists = existingArtists.data.find( + (a: LidarrArtist) => + a.foreignArtistId === artistData.foreignArtistId || + (mbid && a.foreignArtistId === mbid) + ); + + if (exists) { + console.log(`Artist already in Lidarr: ${artistName}`); + + // If this is a discovery add and artist doesn't have discovery tag, add it + if (isDiscovery && discoveryTagId) { + const existingTags = exists.tags || []; + if (!existingTags.includes(discoveryTagId)) { + console.log(` Adding discovery tag to existing artist...`); + await this.addTagsToArtist(exists.id, [discoveryTagId]); + } + } + + // If monitorAllAlbums is true, update the artist to monitor all albums + if (monitorAllAlbums) { + console.log(` Updating artist to monitor all albums...`); + try { + // Update artist settings + const updated = await this.client.put( + `/api/v1/artist/${exists.id}`, + { + ...exists, + monitored: true, + monitorNewItems: "all", + } + ); + + // Get all albums for this artist and monitor them + const albumsResponse = await this.client.get( + `/api/v1/album?artistId=${exists.id}` + ); + const albums = albumsResponse.data; + + console.log( + ` Found ${albums.length} albums to monitor` + ); + + // Monitor all albums + for (const album of albums) { + if (!album.monitored) { + await this.client.put( + `/api/v1/album/${album.id}`, + { + ...album, + monitored: true, + } + ); + } + } + + // Trigger search for all albums if requested + if (searchForMissingAlbums && albums.length > 0) { + console.log( + ` Triggering search for ${albums.length} albums...` + ); + await this.client.post("/api/v1/command", { + name: "AlbumSearch", + albumIds: albums.map((a: any) => a.id), + }); + } + + console.log( + ` Updated existing artist and monitored all albums` + ); + return updated.data; + } catch (error: any) { + console.error( + ` Failed to update artist:`, + error.message + ); + // Return original artist if update fails + return exists; + } + } + + return exists; + } + + // Add artist - use "existing" monitor option to ensure album catalog is fetched + // even if we don't want to download all albums + const artistPayload: any = { + ...artistData, + rootFolderPath: validRootFolder, + qualityProfileId: 1, // Uses default profile - could be made configurable via settings + metadataProfileId: 1, + monitored: true, + monitorNewItems: monitorAllAlbums ? "all" : "none", + addOptions: { + monitor: "existing", // Always fetch album catalog, but don't monitor unless requested + searchForMissingAlbums, + }, + }; + + // Apply discovery tag if this is a discovery add + if (discoveryTagId) { + artistPayload.tags = [discoveryTagId]; + } + + const response = await this.client.post("/api/v1/artist", artistPayload); + + console.log(`Added artist to Lidarr: ${artistName}${isDiscovery ? " (tagged as discovery)" : ""}`); + + // Trigger metadata refresh to ensure album catalog is populated + if (!searchForMissingAlbums) { + console.log(` Triggering metadata refresh for new artist...`); + try { + await this.client.post("/api/v1/command", { + name: "RefreshArtist", + artistId: response.data.id, + }); + } catch (refreshError) { + console.warn(` Metadata refresh command failed (non-blocking)`); + } + } + + return response.data; + } catch (error: any) { + console.error( + "Lidarr add artist error:", + error.response?.data || error.message + ); + return null; + } + } + + async searchAlbum( + artistName: string, + albumTitle: string, + rgMbid?: string + ): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + const searchTerm = rgMbid + ? `lidarr:${rgMbid}` + : `${artistName} ${albumTitle}`; + console.log(` Searching Lidarr for album: ${searchTerm}`); + + const response = await this.client.get("/api/v1/album/lookup", { + params: { + term: searchTerm, + }, + }); + + console.log(` Found ${response.data.length} album result(s)`); + return response.data; + } catch (error: any) { + console.error(` ✗ Lidarr album search error: ${error.message}`); + if (error.response?.data) { + console.error(` Response:`, error.response.data); + } + return []; + } + } + + /** + * Get all albums for an artist that exist in Lidarr's catalog + * Used for same-artist fallback to avoid trying MusicBrainz albums that Lidarr can't find + */ + async getArtistAlbums(artistMbid: string): Promise { + if (!this.client) { + console.warn("Lidarr not enabled"); + return []; + } + + try { + // First find the artist in Lidarr + const artistsResponse = await this.client.get("/api/v1/artist"); + const artist = artistsResponse.data.find( + (a: LidarrArtist) => a.foreignArtistId === artistMbid + ); + + if (!artist) { + console.log(` Artist not found in Lidarr: ${artistMbid}`); + return []; + } + + // Get albums for this artist + const albumsResponse = await this.client.get(`/api/v1/album?artistId=${artist.id}`); + return albumsResponse.data || []; + } catch (error: any) { + console.error(` Failed to get artist albums: ${error.message}`); + return []; + } + } + + async addAlbum( + rgMbid: string, + artistName: string, + albumTitle: string, + rootFolderPath: string = "/music", + artistMbid?: string, + isDiscovery: boolean = false + ): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + console.log(` Adding album: ${albumTitle} by ${artistName}${isDiscovery ? " (discovery)" : ""}`); + console.log(` Album MBID: ${rgMbid}`); + console.log(` Artist MBID: ${artistMbid || "none"}`); + + // NEW APPROACH: Add artist first, then find album in their catalog + // This avoids the broken external album search API + + // Check if artist exists + const existingArtists = await this.client.get("/api/v1/artist"); + let artist = existingArtists.data.find( + (a: LidarrArtist) => + artistMbid && a.foreignArtistId === artistMbid + ); + + let justAddedArtist = false; + + // If discovery and artist exists, ensure they have the discovery tag + if (isDiscovery && artist) { + const discoveryTagId = await this.getOrCreateDiscoveryTag(); + if (discoveryTagId) { + const existingTags = artist.tags || []; + if (!existingTags.includes(discoveryTagId)) { + console.log(` Adding discovery tag to existing artist...`); + await this.addTagsToArtist(artist.id, [discoveryTagId]); + } + } + } + + if (!artist && artistMbid) { + console.log(` Adding artist first: ${artistName}`); + + // Add artist WITHOUT searching for all albums + // Pass isDiscovery to tag the artist appropriately + artist = await this.addArtist( + artistMbid, + artistName, + rootFolderPath, + false, // Don't auto-download all albums + false, // Don't monitor all albums + isDiscovery // Tag as discovery if this is a discovery download + ); + + if (!artist) { + console.error(` ✗ Failed to add artist`); + return null; + } + + justAddedArtist = true; + console.log( + ` Artist added: ${artist.artistName} (ID: ${artist.id})` + ); + console.log( + ` Waiting for Lidarr to populate album catalog...` + ); + } else if (!artist) { + console.error(` ✗ Artist not found and no MBID provided`); + return null; + } else { + console.log( + ` Artist already exists: ${artist.artistName} (ID: ${artist.id})` + ); + } + + // Get artist's albums from Lidarr + let artistAlbums: LidarrAlbum[] = []; + + // First check - get current album list + const artistAlbumsResponse = await this.client.get( + `/api/v1/album?artistId=${artist.id}` + ); + artistAlbums = artistAlbumsResponse.data; + + // If we just added the artist and no albums yet, wait for metadata to populate + if (artistAlbums.length === 0 && justAddedArtist) { + console.log(` Waiting for Lidarr to fetch album metadata...`); + + // Increased timeout: 15 attempts * 3 seconds = 45 seconds total + // Large artist catalogs (e.g., prolific bands) need more time + const maxAttempts = 15; + const retryDelay = 3000; // 3 seconds between retries + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + + const retryResponse = await this.client.get( + `/api/v1/album?artistId=${artist.id}` + ); + artistAlbums = retryResponse.data; + + if (artistAlbums.length > 0) { + console.log(` Albums loaded after ${attempt * 3}s`); + break; + } + + if (attempt < maxAttempts) { + console.log(` Attempt ${attempt}/${maxAttempts}: Still waiting...`); + } + } + } else if (artistAlbums.length === 0 && !justAddedArtist) { + // Artist exists but has 0 albums - try refreshing metadata once + console.log(` Artist exists but has 0 albums - refreshing metadata...`); + try { + await this.client.post("/api/v1/command", { + name: "RefreshArtist", + artistId: artist.id, + }); + // Wait for refresh to complete + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const retryResponse = await this.client.get( + `/api/v1/album?artistId=${artist.id}` + ); + artistAlbums = retryResponse.data; + } catch (refreshError) { + console.warn(` Metadata refresh failed`); + } + } + + console.log( + ` Found ${artistAlbums.length} albums for ${artist.artistName}` + ); + + // Find the specific album by MBID first + let albumData = artistAlbums.find( + (a: LidarrAlbum) => a.foreignAlbumId === rgMbid + ); + + // If MBID doesn't match, try STRICT name matching + // IMPORTANT: We removed loose matching (base name, first word) because it caused + // wrong albums to be downloaded (e.g., "A Trip To The Mystery Planet" matching "A Funk Odyssey") + if (!albumData) { + console.log( + ` Album MBID not found, trying STRICT name match for: ${albumTitle}` + ); + + // Normalize title for matching - remove parenthetical suffixes, edition markers, etc. + const normalizeTitle = (title: string) => + title + .toLowerCase() + .replace(/\(.*?\)/g, "") // Remove parenthetical content (deluxe edition, remaster, etc.) + .replace(/\[.*?\]/g, "") // Remove bracketed content + .replace(/[-–—]\s*(deluxe|remaster|bonus|special|anniversary|expanded|limited|collector).*$/i, "") // Remove edition suffixes + .replace(/[^\w\s]/g, "") // Remove remaining punctuation + .replace(/\s+/g, " ") // Normalize whitespace + .trim(); + + const targetTitle = normalizeTitle(albumTitle); + console.log(` Normalized target: "${targetTitle}"`); + + // Try exact normalized match first + albumData = artistAlbums.find( + (a: LidarrAlbum) => normalizeTitle(a.title) === targetTitle + ); + if (albumData) { + console.log(` ✓ Matched exact normalized: "${albumData.title}"`); + } + + // Try partial match ONLY if one contains the other completely + // This handles "Album Name" matching "Album Name (Deluxe Edition)" + if (!albumData) { + albumData = artistAlbums.find((a: LidarrAlbum) => { + const normalized = normalizeTitle(a.title); + // Only match if one is a substring of the other AND they share significant content + // The shorter one must be at least 60% of the longer one's length + const shorter = normalized.length < targetTitle.length ? normalized : targetTitle; + const longer = normalized.length >= targetTitle.length ? normalized : targetTitle; + if (longer.includes(shorter) && shorter.length >= longer.length * 0.6) { + return true; + } + return false; + }); + if (albumData) { + console.log(` ✓ Matched partial (contained): "${albumData.title}"`); + } + } + + // NO base name matching - this caused wrong albums to be matched + // NO first word matching - this caused wrong albums to be matched + // If we don't have an exact or contained match, we should FAIL + // and let the discovery system find a different album + + if (albumData) { + console.log( + ` Final match: "${albumData.title}" (MBID: ${albumData.foreignAlbumId})` + ); + } else { + console.log(` ✗ No strict match found - will NOT use loose matching to avoid wrong albums`); + } + } + + if (!albumData) { + console.error( + ` ✗ Album "${albumTitle}" not found in artist's ${artistAlbums.length} albums` + ); + if (artistAlbums.length > 0) { + console.log(` Looking for: "${albumTitle}" (MBID: ${rgMbid})`); + console.log(` Available albums in Lidarr (showing up to 10):`); + artistAlbums.slice(0, 10).forEach((a: LidarrAlbum) => { + console.log(` - "${a.title}" (${a.foreignAlbumId})`); + }); + } + // Return null - let the caller handle replacement logic + // We should NOT download a random album that isn't what was requested + return null; + } + + console.log(` Found album in catalog: ${albumData.title} (ID: ${albumData.id})`); + + // Ensure artist is monitored (might have been added with monitoring disabled) + if (!artist.monitored) { + console.log(` Enabling artist monitoring...`); + await this.client.put(`/api/v1/artist/${artist.id}`, { + ...artist, + monitored: true, + }); + console.log(` Artist monitoring enabled`); + } else { + console.log(` Artist already monitored`); + } + + // CRITICAL: Fetch the FULL album data from Lidarr + // The album list endpoint may return incomplete data + console.log(` Fetching full album data from Lidarr...`); + const fullAlbumResponse = await this.client.get(`/api/v1/album/${albumData.id}`); + const fullAlbumData = fullAlbumResponse.data; + + console.log(` Full album data retrieved:`, JSON.stringify({ + id: fullAlbumData.id, + title: fullAlbumData.title, + monitored: fullAlbumData.monitored, + foreignAlbumId: fullAlbumData.foreignAlbumId, + anyReleaseOk: fullAlbumData.anyReleaseOk, + profileId: fullAlbumData.profileId, + releases: fullAlbumData.releases?.length || 0, + }, null, 2)); + + // ALWAYS monitor and search for the album, even if already monitored + // This ensures Lidarr picks up the request + // Preserve user's anyReleaseOk setting - we'll only change it if search fails later + console.log(` Setting album monitoring to true...`); + + const updateResponse = await this.client.put( + `/api/v1/album/${fullAlbumData.id}`, + { + ...fullAlbumData, + monitored: true, + } + ); + + console.log(` PUT response monitored: ${updateResponse.data.monitored}`); + + // CRITICAL: Re-fetch the album to verify the change actually persisted + const verifyResponse = await this.client.get(`/api/v1/album/${fullAlbumData.id}`); + const verifiedMonitored = verifyResponse.data.monitored; + + console.log(` Album monitoring VERIFIED after re-fetch: ${verifiedMonitored}`); + + if (!verifiedMonitored) { + console.error(` ✗ CRITICAL: Album monitoring failed to persist!`); + console.error(` Full album data we sent:`, JSON.stringify(fullAlbumData, null, 2).slice(0, 500)); + console.error(` Response from GET after PUT:`, JSON.stringify(verifyResponse.data, null, 2).slice(0, 500)); + } + + // Use the verified album data + const updatedAlbum = verifyResponse.data; + + // Check if album has releases - if not, refresh artist metadata from MusicBrainz + const releaseCount = updatedAlbum.releases?.length || 0; + if (releaseCount === 0) { + console.warn( + ` Album has 0 releases - refreshing artist metadata from MusicBrainz...` + ); + + // Trigger artist refresh to fetch latest metadata + await this.client.post("/api/v1/command", { + name: "RefreshArtist", + artistId: artist.id, + }); + + console.log(` Waiting for metadata refresh to complete...`); + // Wait for refresh to complete (Lidarr processes this asynchronously) + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Re-fetch the album to see if releases were populated + const refreshedAlbumResponse = await this.client.get( + `/api/v1/album/${updatedAlbum.id}` + ); + const refreshedAlbum = refreshedAlbumResponse.data; + const newReleaseCount = refreshedAlbum.releases?.length || 0; + + console.log( + ` After refresh: ${newReleaseCount} releases found` + ); + + if (newReleaseCount === 0) { + console.warn(` Still no releases after refresh!`); + console.warn( + ` This album may not be properly indexed in MusicBrainz yet.` + ); + console.warn(` Download will be attempted but may fail.`); + } + } + + // ALWAYS trigger search to download the album + console.log(` Triggering album search command for album ID ${updatedAlbum.id}...`); + const searchResponse = await this.client.post("/api/v1/command", { + name: "AlbumSearch", + albumIds: [updatedAlbum.id], + }); + console.log( + ` Search command sent (Command ID: ${searchResponse.data.id})` + ); + + // Wait a moment and check if search found anything + await new Promise((resolve) => setTimeout(resolve, 3000)); + const commandStatus = await this.client.get(`/api/v1/command/${searchResponse.data.id}`); + console.log(` Search result: ${commandStatus.data.message || 'pending'}`); + + if (commandStatus.data.message?.includes('0 reports')) { + // Check if anyReleaseOk is already true - if not, try enabling it + if (!updatedAlbum.anyReleaseOk) { + console.log(` [RETRY] No results with strict matching. Trying with anyReleaseOk=true...`); + + // Re-fetch album to ensure we have latest data + const refetchResponse = await this.client.get(`/api/v1/album/${updatedAlbum.id}`); + const refetchedAlbum = refetchResponse.data; + + // Enable anyReleaseOk + await this.client.put(`/api/v1/album/${updatedAlbum.id}`, { + ...refetchedAlbum, + anyReleaseOk: true, + }); + console.log(` Set anyReleaseOk=true for album`); + + // Retry search + console.log(` Retrying album search...`); + const retryResponse = await this.client.post("/api/v1/command", { + name: "AlbumSearch", + albumIds: [updatedAlbum.id], + }); + + // Wait and check retry result + await new Promise((resolve) => setTimeout(resolve, 3000)); + const retryStatus = await this.client.get(`/api/v1/command/${retryResponse.data.id}`); + console.log(` Retry search result: ${retryStatus.data.message || 'pending'}`); + + if (retryStatus.data.message?.includes('0 reports')) { + console.warn(` [FAIL] Still no releases found even with anyReleaseOk=true.`); + throw new Error("No releases available - indexers found no matching downloads"); + } else { + console.log(` ✓ Found releases after enabling anyReleaseOk`); + } + } else { + console.warn(` [FAIL] No releases grabbed automatically (anyReleaseOk already true).`); + throw new Error("No releases available - indexers found no matching downloads"); + } + } + + console.log(` Album download started: ${updatedAlbum.title}`); + return updatedAlbum; + } catch (error: any) { + // Re-throw our own errors (like "No releases available") + if (error.message?.includes("No releases available")) { + throw error; + } + console.error( + "Lidarr add album error:", + error.response?.data || error.message + ); + return null; + } + } + + async rescanLibrary(): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + await this.client.post("/api/v1/command", { + name: "RescanFolders", + }); + + console.log("Triggered Lidarr library rescan"); + } catch (error) { + console.error("Lidarr rescan error:", error); + throw error; + } + } + + async getArtists(): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return []; + } + + try { + const response = await this.client.get("/api/v1/artist"); + return response.data; + } catch (error) { + console.error("Lidarr get artists error:", error); + return []; + } + } + + /** + * Delete an artist from Lidarr by MusicBrainz ID + * This removes the artist and optionally deletes files + */ + async deleteArtist( + mbid: string, + deleteFiles: boolean = true + ): Promise<{ success: boolean; message: string }> { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return { success: false, message: "Lidarr not enabled or configured" }; + } + + if (!mbid || mbid.startsWith("temp-")) { + return { success: false, message: "Invalid or temporary MBID" }; + } + + try { + // Find artist in Lidarr by foreignArtistId (MBID) + const artists = await this.getArtists(); + const lidarrArtist = artists.find(a => a.foreignArtistId === mbid); + + if (!lidarrArtist) { + console.log(`[LIDARR] Artist with MBID ${mbid} not found in Lidarr`); + return { success: true, message: "Artist not in Lidarr (already removed or never added)" }; + } + + console.log(`[LIDARR] Deleting artist: ${lidarrArtist.artistName} (ID: ${lidarrArtist.id})`); + + // Delete the artist from Lidarr (with timeout to prevent hanging) + await this.client.delete(`/api/v1/artist/${lidarrArtist.id}`, { + params: { + deleteFiles: deleteFiles, + addImportListExclusion: false, + }, + timeout: 30000, // 30 second timeout + }); + + console.log(`[LIDARR] Successfully deleted artist: ${lidarrArtist.artistName}`); + return { success: true, message: `Deleted ${lidarrArtist.artistName} from Lidarr` }; + } catch (error: any) { + console.error("[LIDARR] Delete artist error:", error?.message || error); + return { success: false, message: error?.message || "Failed to delete from Lidarr" }; + } + } + + /** + * Delete an album from Lidarr by Lidarr album ID + * This unmonitors the album and optionally deletes files + */ + async deleteAlbum( + lidarrAlbumId: number, + deleteFiles: boolean = true + ): Promise<{ success: boolean; message: string }> { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return { success: false, message: "Lidarr not enabled or configured" }; + } + + try { + console.log(`[LIDARR] Deleting album ID: ${lidarrAlbumId}`); + + // First get the album to check for track files + const albumResponse = await this.client.get(`/api/v1/album/${lidarrAlbumId}`); + const album = albumResponse.data; + const artistId = album.artistId; + const albumTitle = album.title || "Unknown"; + + if (deleteFiles) { + // Get track files for this album + const trackFilesResponse = await this.client.get("/api/v1/trackFile", { + params: { albumId: lidarrAlbumId }, + }); + + const trackFiles = trackFilesResponse.data; + + if (trackFiles && trackFiles.length > 0) { + // Delete each track file + for (const trackFile of trackFiles) { + try { + await this.client.delete(`/api/v1/trackFile/${trackFile.id}`); + } catch (e) { + // Ignore individual file deletion errors + } + } + console.log(`[LIDARR] Deleted ${trackFiles.length} track files for album: ${albumTitle}`); + } + } + + // Unmonitor the album (don't delete the album record, just unmonitor) + await this.client.put(`/api/v1/album/${lidarrAlbumId}`, { + ...album, + monitored: false, + }); + + console.log(`[LIDARR] Successfully unmonitored album: ${albumTitle}`); + return { success: true, message: `Deleted files and unmonitored ${albumTitle}` }; + } catch (error: any) { + console.error("[LIDARR] Delete album error:", error?.message || error); + return { success: false, message: error?.message || "Failed to delete album from Lidarr" }; + } + } + + /** + * Check if an album exists in Lidarr and has files (already downloaded) + * Returns true if the album is already available in Lidarr + */ + async isAlbumAvailable(albumMbid: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return false; + } + + try { + // Search for the album by MBID + const response = await this.client.get("/api/v1/album", { + params: { foreignAlbumId: albumMbid }, + }); + + const albums = response.data; + if (!albums || albums.length === 0) { + return false; + } + + // Check if any matching album has files (statistics.percentOfTracks > 0) + for (const album of albums) { + if (album.foreignAlbumId === albumMbid) { + // Album exists in Lidarr - check if it has files + const hasFiles = album.statistics?.percentOfTracks > 0; + if (hasFiles) { + return true; + } + } + } + + return false; + } catch (error: any) { + // If 404 or other error, album doesn't exist + if (error.response?.status === 404) { + return false; + } + console.error("Lidarr album check error:", error.message); + return false; + } + } + + /** + * Check if an album exists in Lidarr by artist name and album title + * Handles MBID mismatches between MusicBrainz and Lidarr + */ + async isAlbumAvailableByTitle(artistName: string, albumTitle: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return false; + } + + const normalizedArtist = artistName.toLowerCase().trim(); + const normalizedAlbum = albumTitle.toLowerCase().trim(); + + try { + // Get all artists from Lidarr + const artistsResponse = await this.client.get("/api/v1/artist"); + const artists = artistsResponse.data || []; + + // Find matching artist by name + const matchingArtist = artists.find((a: any) => + a.artistName?.toLowerCase().trim() === normalizedArtist || + a.sortName?.toLowerCase().trim() === normalizedArtist + ); + + if (!matchingArtist) { + return false; + } + + // Get albums for this artist + const albumsResponse = await this.client.get("/api/v1/album", { + params: { artistId: matchingArtist.id }, + }); + const albums = albumsResponse.data || []; + + // Check if any album matches the title and has files + for (const album of albums) { + const albumTitleNorm = album.title?.toLowerCase().trim() || ""; + if (albumTitleNorm === normalizedAlbum || albumTitleNorm.includes(normalizedAlbum)) { + const hasFiles = album.statistics?.percentOfTracks > 0; + if (hasFiles) { + return true; + } + } + } + + return false; + } catch (error: any) { + console.error("Lidarr album check by title error:", error.message); + return false; + } + } + + /** + * Check if an artist exists in Lidarr + */ + async isArtistInLidarr(artistMbid: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return false; + } + + try { + const response = await this.client.get("/api/v1/artist"); + const artists = response.data; + return artists.some((a: any) => a.foreignArtistId === artistMbid); + } catch (error) { + return false; + } + } + + // ============================================ + // Tag Management Methods (for discovery tracking) + // ============================================ + + /** + * Get all tags from Lidarr + */ + async getTags(): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return []; + } + + try { + const response = await this.client.get("/api/v1/tag"); + return response.data || []; + } catch (error: any) { + console.error("[LIDARR] Failed to get tags:", error.message); + return []; + } + } + + /** + * Create a new tag in Lidarr + */ + async createTag(label: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return null; + } + + try { + const response = await this.client.post("/api/v1/tag", { label }); + console.log(`[LIDARR] Created tag: ${label} (ID: ${response.data.id})`); + return response.data; + } catch (error: any) { + console.error("[LIDARR] Failed to create tag:", error.message); + return null; + } + } + + /** + * Get or create the discovery tag + * Returns the tag ID, caching it for subsequent calls + */ + private discoveryTagId: number | null = null; + + async getOrCreateDiscoveryTag(): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return null; + } + + // Return cached tag ID if available + if (this.discoveryTagId !== null) { + return this.discoveryTagId; + } + + try { + // Check if tag already exists + const tags = await this.getTags(); + const existingTag = tags.find(t => t.label === DISCOVERY_TAG_LABEL); + + if (existingTag) { + console.log(`[LIDARR] Found existing discovery tag (ID: ${existingTag.id})`); + this.discoveryTagId = existingTag.id; + return existingTag.id; + } + + // Create the tag + const newTag = await this.createTag(DISCOVERY_TAG_LABEL); + if (newTag) { + this.discoveryTagId = newTag.id; + return newTag.id; + } + + return null; + } catch (error: any) { + console.error("[LIDARR] Failed to get/create discovery tag:", error.message); + return null; + } + } + + /** + * Add tags to an artist + */ + async addTagsToArtist(artistId: number, tagIds: number[]): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return false; + } + + try { + // Get current artist data + const response = await this.client.get(`/api/v1/artist/${artistId}`); + const artist = response.data; + + // Merge new tags with existing (avoid duplicates) + const existingTags = artist.tags || []; + const mergedTags = [...new Set([...existingTags, ...tagIds])]; + + // Update artist with new tags + await this.client.put(`/api/v1/artist/${artistId}`, { + ...artist, + tags: mergedTags, + }); + + console.log(`[LIDARR] Added tags ${tagIds} to artist ${artist.artistName}`); + return true; + } catch (error: any) { + console.error("[LIDARR] Failed to add tags to artist:", error.message); + return false; + } + } + + /** + * Remove tags from an artist + */ + async removeTagsFromArtist(artistId: number, tagIds: number[]): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return false; + } + + try { + // Get current artist data + const response = await this.client.get(`/api/v1/artist/${artistId}`); + const artist = response.data; + + // Remove specified tags + const existingTags = artist.tags || []; + const filteredTags = existingTags.filter((t: number) => !tagIds.includes(t)); + + // Update artist with filtered tags + await this.client.put(`/api/v1/artist/${artistId}`, { + ...artist, + tags: filteredTags, + }); + + console.log(`[LIDARR] Removed tags ${tagIds} from artist ${artist.artistName}`); + return true; + } catch (error: any) { + console.error("[LIDARR] Failed to remove tags from artist:", error.message); + return false; + } + } + + /** + * Get all artists that have a specific tag + */ + async getArtistsByTag(tagId: number): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return []; + } + + try { + const response = await this.client.get("/api/v1/artist"); + const artists: LidarrArtist[] = response.data; + + // Filter artists that have the specified tag + return artists.filter(artist => artist.tags?.includes(tagId)); + } catch (error: any) { + console.error("[LIDARR] Failed to get artists by tag:", error.message); + return []; + } + } + + /** + * Get all discovery-tagged artists (convenience method) + */ + async getDiscoveryArtists(): Promise { + const tagId = await this.getOrCreateDiscoveryTag(); + if (!tagId) { + return []; + } + return this.getArtistsByTag(tagId); + } + + /** + * Remove discovery tag from an artist by MBID + * Used when user likes an album (artist becomes "owned") + */ + async removeDiscoveryTagByMbid(artistMbid: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return false; + } + + try { + const tagId = await this.getOrCreateDiscoveryTag(); + if (!tagId) { + return false; + } + + // Find artist by MBID + const artists = await this.getArtists(); + const artist = artists.find(a => a.foreignArtistId === artistMbid); + + if (!artist) { + console.log(`[LIDARR] Artist ${artistMbid} not found in Lidarr`); + return true; // Not an error - artist might not be in Lidarr + } + + // Check if artist has the discovery tag + if (!artist.tags?.includes(tagId)) { + console.log(`[LIDARR] Artist ${artist.artistName} doesn't have discovery tag`); + return true; // Already doesn't have tag + } + + return await this.removeTagsFromArtist(artist.id, [tagId]); + } catch (error: any) { + console.error("[LIDARR] Failed to remove discovery tag:", error.message); + return false; + } + } + + /** + * Delete artist by Lidarr ID (used for cleanup) + */ + async deleteArtistById( + lidarrId: number, + deleteFiles: boolean = true + ): Promise<{ success: boolean; message: string }> { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return { success: false, message: "Lidarr not enabled" }; + } + + try { + await this.client.delete(`/api/v1/artist/${lidarrId}`, { + params: { + deleteFiles, + addImportListExclusion: false, + }, + timeout: 30000, + }); + + return { success: true, message: "Artist deleted" }; + } catch (error: any) { + if (error.response?.status === 404) { + return { success: true, message: "Artist already removed" }; + } + console.error("[LIDARR] Delete artist by ID error:", error.message); + return { success: false, message: error.message }; + } + } + + // ============================================ + // Release Iteration Methods (for exhaustive retry) + // ============================================ + + /** + * Get all available releases for an album from all indexers + * This is what Lidarr's "Interactive Search" uses + */ + async getAlbumReleases(lidarrAlbumId: number): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + console.log(`[LIDARR] Fetching releases for album ID: ${lidarrAlbumId}`); + const response = await this.client.get("/api/v1/release", { + params: { albumId: lidarrAlbumId }, + timeout: 60000, // 60s timeout for indexer searches + }); + + const releases: LidarrRelease[] = response.data || []; + console.log(`[LIDARR] Found ${releases.length} releases from indexers`); + + // Sort by preferred criteria (Lidarr already sorts by quality/preferred words) + // but we can add seeders as a secondary sort for torrents + releases.sort((a, b) => { + // Approved releases first + if (a.approved && !b.approved) return -1; + if (!a.approved && b.approved) return 1; + + // Higher seeders for torrents + if (a.seeders !== undefined && b.seeders !== undefined) { + return b.seeders - a.seeders; + } + + // Keep original order (Lidarr's quality sorting) + return 0; + }); + + return releases; + } catch (error: any) { + console.error(`[LIDARR] Failed to fetch releases:`, error.message); + return []; + } + } + + /** + * Grab (download) a specific release by GUID + * This tells Lidarr to download the specified release + */ + async grabRelease(release: LidarrRelease): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + console.log(`[LIDARR] Grabbing release: ${release.title}`); + console.log(` GUID: ${release.guid}`); + console.log(` Indexer: ${release.indexer || 'unknown'}`); + console.log(` Size: ${Math.round((release.size || 0) / 1024 / 1024)} MB`); + + await this.client.post("/api/v1/release", { + guid: release.guid, + indexerId: release.indexerId || 0, + }); + + console.log(`[LIDARR] Release grabbed successfully`); + return true; + } catch (error: any) { + console.error(`[LIDARR] Failed to grab release:`, error.response?.data || error.message); + return false; + } + } + + /** + * Remove a download from queue and blocklist the release + * Use skipRedownload=true since we'll manually grab the next release + */ + async blocklistAndRemove(downloadId: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + throw new Error("Lidarr not enabled"); + } + + try { + // Find the queue item by downloadId + const queueResponse = await this.client.get("/api/v1/queue", { + params: { page: 1, pageSize: 100 }, + }); + + const queueItem = queueResponse.data.records.find( + (item: any) => item.downloadId === downloadId + ); + + if (!queueItem) { + console.log(`[LIDARR] Download ${downloadId} not found in queue (may already be removed)`); + return true; // Consider it success if not in queue + } + + console.log(`[LIDARR] Blocklisting and removing: ${queueItem.title}`); + + await this.client.delete(`/api/v1/queue/${queueItem.id}`, { + params: { + removeFromClient: true, + blocklist: true, + skipRedownload: true, // We'll grab the next release manually + }, + }); + + console.log(`[LIDARR] Successfully blocklisted: ${queueItem.title}`); + return true; + } catch (error: any) { + console.error(`[LIDARR] Failed to blocklist:`, error.response?.data || error.message); + return false; + } + } + + /** + * Find queue item by download ID + */ + async findQueueItemByDownloadId(downloadId: string): Promise { + await this.ensureInitialized(); + + if (!this.enabled || !this.client) { + return null; + } + + try { + const response = await this.client.get("/api/v1/queue", { + params: { page: 1, pageSize: 100 }, + }); + + return response.data.records.find( + (item: any) => item.downloadId === downloadId + ) || null; + } catch (error: any) { + console.error(`[LIDARR] Failed to find queue item:`, error.message); + return null; + } + } + + /** + * Get upcoming and recent releases from Lidarr calendar + * Returns albums releasing within the specified date range for monitored artists + */ + async getCalendar(startDate: Date, endDate: Date): Promise { + await this.ensureInitialized(); + + if (!this.client) { + console.log("[LIDARR] Not configured - cannot fetch calendar"); + return []; + } + + try { + const start = startDate.toISOString().split('T')[0]; + const end = endDate.toISOString().split('T')[0]; + + const response = await this.client.get(`/api/v1/calendar`, { + params: { + start, + end, + includeArtist: true, + } + }); + + const releases: CalendarRelease[] = response.data.map((album: any) => ({ + id: album.id, + title: album.title, + artistName: album.artist?.artistName || 'Unknown Artist', + artistId: album.artist?.id, + artistMbid: album.artist?.foreignArtistId, + albumMbid: album.foreignAlbumId, + releaseDate: album.releaseDate, + monitored: album.monitored, + grabbed: album.grabbed || false, + hasFile: album.statistics?.percentOfTracks === 100, + coverUrl: album.images?.find((img: any) => img.coverType === 'cover')?.remoteUrl || null, + })); + + console.log(`[LIDARR] Calendar: Found ${releases.length} releases between ${start} and ${end}`); + return releases; + } catch (error: any) { + console.error(`[LIDARR] Failed to fetch calendar:`, error.message); + return []; + } + } + + /** + * Get all monitored artists from Lidarr + */ + async getMonitoredArtists(): Promise<{ id: number; name: string; mbid: string }[]> { + await this.ensureInitialized(); + + if (!this.client) { + return []; + } + + try { + const response = await this.client.get(`/api/v1/artist`); + return response.data + .filter((artist: any) => artist.monitored) + .map((artist: any) => ({ + id: artist.id, + name: artist.artistName, + mbid: artist.foreignArtistId, + })); + } catch (error: any) { + console.error(`[LIDARR] Failed to fetch monitored artists:`, error.message); + return []; + } + } +} + +// Interface for calendar release data +export interface CalendarRelease { + id: number; + title: string; + artistName: string; + artistId?: number; + artistMbid?: string; + albumMbid: string; + releaseDate: string; + monitored: boolean; + grabbed: boolean; + hasFile: boolean; + coverUrl: string | null; +} + +// Interface for release data from Lidarr (exported for use by simpleDownloadManager) +export interface LidarrRelease { + guid: string; + title: string; + indexerId: number; + indexer?: string; + size?: number; + seeders?: number; + leechers?: number; + protocol: string; // usenet, torrent + approved: boolean; + rejected: boolean; + rejections?: string[]; + quality?: { + quality: { name: string }; + }; +} + +export const lidarrService = new LidarrService(); + +// ============================================ +// Queue Cleaner Functions +// ============================================ + +// Types for queue monitoring +interface QueueItem { + id: number; + title: string; + status: string; + downloadId: string; + trackedDownloadStatus: string; + trackedDownloadState: string; + statusMessages: { title: string; messages: string[] }[]; +} + +interface QueueResponse { + page: number; + pageSize: number; + totalRecords: number; + records: QueueItem[]; +} + +interface HistoryRecord { + id: number; + albumId: number; + downloadId: string; + eventType: string; + date: string; + data: { + droppedPath?: string; + importedPath?: string; + }; + album: { + id: number; + title: string; + foreignAlbumId: string; // MBID + }; + artist: { + name: string; + }; +} + +interface HistoryResponse { + page: number; + pageSize: number; + totalRecords: number; + records: HistoryRecord[]; +} + +// Patterns that indicate a stuck download (case-insensitive matching) +const FAILED_IMPORT_PATTERNS = [ + // Import issues + "No files found are eligible for import", + "Not an upgrade for existing", + "Not a Custom Format upgrade", + "Has missing tracks", // Individual tracks from discography packs + "missing tracks", + "Album match is not close enough", // Lidarr matching threshold failure + "Artist name mismatch", // Manual import required - artist doesn't match + "automatic import is not possible", // Generic auto-import failure + // Unpack/extraction failures + "Unable to extract", + "Failed to extract", + "Unpacking failed", + "unpack error", + "Error extracting", + "extraction failed", + "corrupt archive", + "invalid archive", + "CRC failed", + "bad archive", + // Download/transfer issues + "Download failed", + "import failed", + "Sample", +]; + +/** + * Clean stuck downloads from Lidarr queue + * Returns items that were removed and will trigger automatic search for alternatives + */ +export async function cleanStuckDownloads( + lidarrUrl: string, + apiKey: string +): Promise<{ removed: number; items: string[] }> { + const removed: string[] = []; + + try { + // Fetch current queue + const response = await axios.get( + `${lidarrUrl}/api/v1/queue`, + { + params: { + page: 1, + pageSize: 100, + includeUnknownArtistItems: true, + }, + headers: { "X-Api-Key": apiKey }, + } + ); + + console.log( + ` Queue cleaner: checking ${response.data.records.length} items` + ); + + for (const item of response.data.records) { + // Check if this item has a failed import message + const allMessages = + item.statusMessages?.flatMap((sm) => sm.messages) || []; + + // Log ALL items to understand what states we're seeing + console.log(` - ${item.title}`); + console.log( + ` Status: ${item.status}, TrackedStatus: ${item.trackedDownloadStatus}, State: ${item.trackedDownloadState}` + ); + if (allMessages.length > 0) { + console.log(` Messages: ${allMessages.join("; ")}`); + } + + // Check for pattern matches in messages + const hasFailedPattern = allMessages.some((msg) => + FAILED_IMPORT_PATTERNS.some((pattern) => + msg.toLowerCase().includes(pattern.toLowerCase()) + ) + ); + + // Also check if trackedDownloadStatus is "warning" with importPending state + // These are items that have finished downloading but can't be imported + const isStuckWarning = + item.trackedDownloadStatus === "warning" && + item.trackedDownloadState === "importPending"; + + // CRITICAL: importFailed state is TERMINAL - will never recover + // Don't wait for timeout, clean up immediately + const isImportFailed = item.trackedDownloadState === "importFailed"; + + const shouldRemove = hasFailedPattern || isStuckWarning || isImportFailed; + + if (shouldRemove) { + const reason = isImportFailed + ? "importFailed state (terminal)" + : hasFailedPattern + ? "failed pattern match" + : "stuck warning state"; + console.log(` [REMOVE] Removing ${item.title} (${reason})`); + + try { + // Remove from queue, blocklist the release, trigger new search + await axios.delete(`${lidarrUrl}/api/v1/queue/${item.id}`, { + params: { + removeFromClient: true, // Remove from NZBGet too + blocklist: true, // Don't try this release again + skipRedownload: false, // DO trigger new search + }, + headers: { "X-Api-Key": apiKey }, + }); + + removed.push(item.title); + console.log(` Removed and blocklisted: ${item.title}`); + } catch (deleteError: any) { + // Item might already be gone - that's fine + if (deleteError.response?.status !== 404) { + console.error( + ` Failed to remove ${item.title}:`, + deleteError.message + ); + } + } + } + } + + if (removed.length > 0) { + console.log( + ` Queue cleaner: removed ${removed.length} stuck item(s)` + ); + } + + return { removed: removed.length, items: removed }; + } catch (error: any) { + console.error("Queue clean failed:", error.message); + throw error; + } +} + +/** + * Get recently completed downloads from Lidarr history + * Used to find orphaned completions (webhooks that never arrived) + */ +export async function getRecentCompletedDownloads( + lidarrUrl: string, + apiKey: string, + sinceMinutes: number = 5 +): Promise { + try { + const response = await axios.get( + `${lidarrUrl}/api/v1/history`, + { + params: { + page: 1, + pageSize: 100, + sortKey: "date", + sortDirection: "descending", + eventType: 3, // 3 = downloadFolderImported (successful import) + }, + headers: { "X-Api-Key": apiKey }, + } + ); + + // Filter to only recent imports (within last X minutes) + const cutoff = new Date(Date.now() - sinceMinutes * 60 * 1000); + return response.data.records.filter((record) => { + return new Date(record.date) >= cutoff; + }); + } catch (error: any) { + console.error("Failed to fetch Lidarr history:", error.message); + throw error; + } +} + +/** + * Get the current queue count from Lidarr + */ +export async function getQueueCount( + lidarrUrl: string, + apiKey: string +): Promise { + try { + const response = await axios.get( + `${lidarrUrl}/api/v1/queue`, + { + params: { + page: 1, + pageSize: 1, + }, + headers: { "X-Api-Key": apiKey }, + } + ); + return response.data.totalRecords; + } catch (error: any) { + console.error("Failed to get queue count:", error.message); + return 0; + } +} + +/** + * Check if a specific download is still actively downloading in Lidarr's queue + * Returns true if actively downloading, false if not found or stuck + */ +export async function isDownloadActive( + downloadId: string +): Promise<{ active: boolean; status?: string; progress?: number }> { + const settings = await getSystemSettings(); + if (!settings?.lidarrEnabled || !settings.lidarrUrl || !settings.lidarrApiKey) { + return { active: false }; + } + + try { + const response = await axios.get( + `${settings.lidarrUrl}/api/v1/queue`, + { + params: { + page: 1, + pageSize: 100, + includeUnknownArtistItems: true, + }, + headers: { "X-Api-Key": settings.lidarrApiKey }, + } + ); + + const item = response.data.records.find(r => r.downloadId === downloadId); + + if (!item) { + return { active: false, status: "not_found" }; + } + + // Check if it's actively downloading (not stuck in warning/failed state) + const isActivelyDownloading = + item.status === "downloading" || + (item.trackedDownloadState === "downloading" && item.trackedDownloadStatus !== "warning"); + + return { + active: isActivelyDownloading, + status: item.trackedDownloadState || item.status, + progress: item.sizeleft && item.size + ? Math.round((1 - item.sizeleft / item.size) * 100) + : undefined + }; + } catch (error: any) { + console.error("Failed to check download status:", error.message); + return { active: false }; + } +} diff --git a/backend/src/services/musicScanner.ts b/backend/src/services/musicScanner.ts new file mode 100644 index 0000000..d40f272 --- /dev/null +++ b/backend/src/services/musicScanner.ts @@ -0,0 +1,766 @@ +import * as fs from "fs"; +import * as path from "path"; +import { parseFile } from "music-metadata"; +import { prisma } from "../utils/db"; +import PQueue from "p-queue"; +import { CoverArtExtractor } from "./coverArtExtractor"; +import { deezerService } from "./deezer"; +import { normalizeArtistName, areArtistNamesSimilar, canonicalizeVariousArtists } from "../utils/artistNormalization"; + +// Supported audio formats +const AUDIO_EXTENSIONS = new Set([ + ".mp3", + ".flac", + ".m4a", + ".aac", + ".ogg", + ".opus", + ".wav", + ".wma", + ".ape", + ".wv", +]); + +interface ScanProgress { + filesScanned: number; + filesTotal: number; + currentFile: string; + errors: Array<{ file: string; error: string }>; +} + +interface ScanResult { + tracksAdded: number; + tracksUpdated: number; + tracksRemoved: number; + errors: Array<{ file: string; error: string }>; + duration: number; +} + +export class MusicScannerService { + private scanQueue = new PQueue({ concurrency: 10 }); + private progressCallback?: (progress: ScanProgress) => void; + private coverArtExtractor?: CoverArtExtractor; + + constructor( + progressCallback?: (progress: ScanProgress) => void, + coverCachePath?: string + ) { + this.progressCallback = progressCallback; + if (coverCachePath) { + this.coverArtExtractor = new CoverArtExtractor(coverCachePath); + } + } + + /** + * Scan the music directory and update the database + */ + async scanLibrary(musicPath: string): Promise { + const startTime = Date.now(); + const result: ScanResult = { + tracksAdded: 0, + tracksUpdated: 0, + tracksRemoved: 0, + errors: [], + duration: 0, + }; + + console.log(`Starting library scan: ${musicPath}`); + + // Step 1: Find all audio files + const audioFiles = await this.findAudioFiles(musicPath); + console.log(`Found ${audioFiles.length} audio files`); + + // Step 2: Get existing tracks from database + const existingTracks = await prisma.track.findMany({ + select: { + id: true, + filePath: true, + fileModified: true, + }, + }); + + const tracksByPath = new Map( + existingTracks.map((t) => [t.filePath, t]) + ); + + // Step 3: Process each audio file + let filesScanned = 0; + const progress: ScanProgress = { + filesScanned: 0, + filesTotal: audioFiles.length, + currentFile: "", + errors: [], + }; + + for (const audioFile of audioFiles) { + await this.scanQueue.add(async () => { + try { + const relativePath = path.relative(musicPath, audioFile); + progress.currentFile = relativePath; + this.progressCallback?.(progress); + + const stats = await fs.promises.stat(audioFile); + const fileModified = stats.mtime; + + const existingTrack = tracksByPath.get(relativePath); + + // Check if file needs updating + if (existingTrack) { + if ( + existingTrack.fileModified && + existingTrack.fileModified >= fileModified + ) { + // File hasn't changed, skip + filesScanned++; + progress.filesScanned = filesScanned; + return; + } + // File changed, will update + result.tracksUpdated++; + } else { + // New file + result.tracksAdded++; + } + + // Extract metadata and update database + await this.processAudioFile( + audioFile, + relativePath, + musicPath + ); + } catch (err: any) { + const error = { + file: audioFile, + error: err.message || String(err), + }; + result.errors.push(error); + progress.errors.push(error); + console.error(`Error processing ${audioFile}:`, err); + } finally { + filesScanned++; + progress.filesScanned = filesScanned; + this.progressCallback?.(progress); + } + }); + } + + await this.scanQueue.onIdle(); + + // Step 4: Remove tracks for files that no longer exist + const scannedPaths = new Set( + audioFiles.map((f) => path.relative(musicPath, f)) + ); + const tracksToRemove = existingTracks.filter( + (t) => !scannedPaths.has(t.filePath) + ); + + if (tracksToRemove.length > 0) { + await prisma.track.deleteMany({ + where: { + id: { in: tracksToRemove.map((t) => t.id) }, + }, + }); + result.tracksRemoved = tracksToRemove.length; + console.log(`Removed ${tracksToRemove.length} missing tracks`); + } + + // Step 5: Clean up orphaned albums (albums with no tracks) + const orphanedAlbums = await prisma.album.findMany({ + where: { + tracks: { none: {} }, + }, + select: { id: true, title: true }, + }); + + if (orphanedAlbums.length > 0) { + console.log(`Removing ${orphanedAlbums.length} orphaned albums...`); + await prisma.album.deleteMany({ + where: { + id: { in: orphanedAlbums.map((a) => a.id) }, + }, + }); + } + + // Step 6: Clean up orphaned artists (artists with no albums) + const orphanedArtists = await prisma.artist.findMany({ + where: { + albums: { none: {} }, + }, + select: { id: true, name: true }, + }); + + if (orphanedArtists.length > 0) { + console.log(`Removing ${orphanedArtists.length} orphaned artists: ${orphanedArtists.map(a => a.name).join(', ')}`); + await prisma.artist.deleteMany({ + where: { + id: { in: orphanedArtists.map((a) => a.id) }, + }, + }); + } + + result.duration = Date.now() - startTime; + console.log( + `Scan complete: +${result.tracksAdded} ~${result.tracksUpdated} -${result.tracksRemoved} (${result.duration}ms)` + ); + + return result; + } + + /** + * Extract the primary artist from collaboration strings + * Examples: + * "CHVRCHES & Robert Smith" -> "CHVRCHES" + * "Artist feat. Someone" -> "Artist" + * "Artist ft. Someone" -> "Artist" + * "Artist, Someone" -> "Artist" + * + * But preserves band names: + * "Earth, Wind & Fire" -> "Earth, Wind & Fire" (kept as-is) + * "The Naked and Famous" -> "The Naked and Famous" (kept as-is) + */ + private extractPrimaryArtist(artistName: string): string { + // Trim whitespace + artistName = artistName.trim(); + + // HIGH PRIORITY: These patterns almost always indicate collaborations + // (not band names) so we always split on them + const definiteCollaborationPatterns = [ + / feat\.? /i, // "feat." or "feat " + / ft\.? /i, // "ft." or "ft " + / featuring /i, + ]; + + for (const pattern of definiteCollaborationPatterns) { + const match = artistName.split(pattern); + if (match.length > 1) { + return match[0].trim(); + } + } + + // LOWER PRIORITY: These might be band names, so only split if the result + // looks like a complete artist name (not truncated) + const ambiguousPatterns = [ + { pattern: / \& /, name: "&" }, // "Earth, Wind & Fire" shouldn't split + { pattern: / and /i, name: "and" }, // "The Naked and Famous" shouldn't split + { pattern: / with /i, name: "with" }, + { pattern: /, /, name: "," }, + ]; + + for (const { pattern } of ambiguousPatterns) { + const parts = artistName.split(pattern); + if (parts.length > 1) { + const firstPart = parts[0].trim(); + const lastWord = firstPart.split(/\s+/).pop()?.toLowerCase() || ""; + + // Don't split if the first part ends with common incomplete words + // These suggest it's a band name, not a collaboration + const incompleteEndings = ["the", "a", "an", "and", "of", ","]; + if (incompleteEndings.includes(lastWord)) { + continue; // Skip this pattern, try the next one + } + + // Don't split if the first part is very short (likely incomplete) + if (firstPart.length < 4) { + continue; + } + + return firstPart; + } + } + + // No collaboration found, return as-is + return artistName; + } + + /** + * Check if a file path is within the discovery folder + * Discovery albums are stored in paths like "discovery/Artist/Album/track.flac" + * or "Discover/Artist/Album/track.flac" (case-insensitive) + */ + private isDiscoveryPath(relativePath: string): boolean { + const normalizedPath = relativePath.toLowerCase().replace(/\\/g, "/"); + // Check if path starts with "discovery/" or "discover/" + return ( + normalizedPath.startsWith("discovery/") || + normalizedPath.startsWith("discover/") + ); + } + + /** + * Normalize string for matching - handles encoding differences between + * file metadata and database records + */ + private normalizeForMatching(str: string): string { + return str + .toLowerCase() + .trim() + .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // Remove diacritics (café → cafe) + .replace(/[''´`]/g, "'") // Normalize apostrophes + .replace(/[""„]/g, '"') // Normalize quotes + .replace(/[–—−]/g, '-') // Normalize dashes + .replace(/\s+/g, ' ') // Collapse whitespace + .replace(/[^\w\s'"-]/g, ''); // Remove other special chars + } + + /** + * Check if an album is part of a discovery download by matching artist name + album title. + * Uses multi-pass matching: exact match first, then partial match as fallback. + */ + private async isDiscoveryDownload( + artistName: string, + albumTitle: string + ): Promise { + if (!artistName || !albumTitle) return false; + + const normalizedArtist = this.normalizeForMatching(artistName); + const normalizedAlbum = this.normalizeForMatching(albumTitle); + + // Also try with primary artist extracted (handles "Artist A feat. Artist B") + const primaryArtist = this.extractPrimaryArtist(artistName); + const normalizedPrimaryArtist = this.normalizeForMatching(primaryArtist); + + console.log(`[Scanner] Checking discovery: "${artistName}" → "${normalizedArtist}"`); + if (primaryArtist !== artistName) { + console.log(`[Scanner] Primary artist: "${primaryArtist}" → "${normalizedPrimaryArtist}"`); + } + console.log(`[Scanner] Album: "${albumTitle}" → "${normalizedAlbum}"`); + + try { + // Get all discovery jobs (pending, processing, or recently completed) + const discoveryJobs = await prisma.downloadJob.findMany({ + where: { + discoveryBatchId: { not: null }, + status: { in: ["pending", "processing", "completed"] }, + }, + }); + + console.log(`[Scanner] Found ${discoveryJobs.length} discovery jobs to check`); + + // Pass 1: Exact match after normalization + for (const job of discoveryJobs) { + const metadata = job.metadata as any; + const jobArtist = this.normalizeForMatching(metadata?.artistName || ""); + const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || ""); + + if ((jobArtist === normalizedArtist || jobArtist === normalizedPrimaryArtist) && jobAlbum === normalizedAlbum) { + console.log(`[Scanner] EXACT MATCH: job ${job.id}`); + return true; + } + } + + // Pass 2: Partial match fallback (handles "Album" vs "Album (Deluxe)") + for (const job of discoveryJobs) { + const metadata = job.metadata as any; + const jobArtist = this.normalizeForMatching(metadata?.artistName || ""); + const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || ""); + + // Try matching both full artist name and extracted primary artist + const artistMatch = jobArtist === normalizedArtist || + jobArtist === normalizedPrimaryArtist || + normalizedArtist.includes(jobArtist) || + jobArtist.includes(normalizedArtist) || + normalizedPrimaryArtist.includes(jobArtist) || + jobArtist.includes(normalizedPrimaryArtist); + const albumMatch = jobAlbum === normalizedAlbum || + normalizedAlbum.includes(jobAlbum) || + jobAlbum.includes(normalizedAlbum); + + if (artistMatch && albumMatch) { + console.log(`[Scanner] PARTIAL MATCH: job ${job.id}`); + console.log(`[Scanner] Job: "${jobArtist}" - "${jobAlbum}"`); + return true; + } + } + + // Pass 3: Album-only match (handles featured artists on discovery albums) + // If the album title matches exactly, this track is likely a featured artist on a discovery album + for (const job of discoveryJobs) { + const metadata = job.metadata as any; + const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || ""); + + if (jobAlbum === normalizedAlbum && normalizedAlbum.length > 3) { + console.log(`[Scanner] ALBUM-ONLY MATCH (featured artist): job ${job.id}`); + console.log(`[Scanner] Track artist "${normalizedArtist}" is likely featured on "${jobAlbum}"`); + return true; + } + } + + // Pass 4: Check DiscoveryAlbum table (for already processed albums) by album title + const discoveryAlbumByTitle = await prisma.discoveryAlbum.findFirst({ + where: { + albumTitle: { equals: albumTitle, mode: "insensitive" }, + status: { in: ["ACTIVE", "LIKED"] }, + }, + }); + + if (discoveryAlbumByTitle) { + console.log(`[Scanner] DiscoveryAlbum match (by title): ${discoveryAlbumByTitle.id}`); + return true; + } + + // Pass 5: Check if artist name matches any discovery album + // This catches cases where Lidarr downloads a different album than requested + // e.g., requested "Broods - Broods" but got "Broods - Evergreen" + const discoveryAlbumByArtist = await prisma.discoveryAlbum.findFirst({ + where: { + artistName: { equals: artistName, mode: "insensitive" }, + status: { in: ["ACTIVE", "LIKED", "DELETED"] }, // Include DELETED to catch cleanup scenarios + }, + }); + + if (discoveryAlbumByArtist) { + // Double-check: only match if this artist has NO library albums yet + // This prevents marking albums from artists that exist in both library and discovery + const existingLibraryAlbum = await prisma.album.findFirst({ + where: { + artist: { name: { equals: artistName, mode: "insensitive" } }, + location: "LIBRARY", + }, + }); + + if (!existingLibraryAlbum) { + console.log(`[Scanner] DiscoveryAlbum match (by artist): ${discoveryAlbumByArtist.id}`); + console.log(`[Scanner] Artist "${artistName}" is a discovery-only artist`); + return true; + } + } + + console.log(`[Scanner] No discovery match found`); + return false; + } catch (error) { + console.error(`[Scanner] Error checking discovery status:`, error); + return false; + } + } + + /** + * Recursively find all audio files in a directory + */ + private async findAudioFiles(dirPath: string): Promise { + const files: string[] = []; + + async function walk(dir: string) { + const entries = await fs.promises.readdir(dir, { + withFileTypes: true, + }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + await walk(fullPath); + } else if (entry.isFile()) { + const ext = path.extname(entry.name).toLowerCase(); + if (AUDIO_EXTENSIONS.has(ext)) { + files.push(fullPath); + } + } + } + } + + await walk(dirPath); + return files; + } + + /** + * Process a single audio file and update database + */ + private async processAudioFile( + absolutePath: string, + relativePath: string, + musicPath: string + ): Promise { + // Extract metadata + const metadata = await parseFile(absolutePath); + const stats = await fs.promises.stat(absolutePath); + + // Parse basic info + const title = + metadata.common.title || + path.basename(relativePath, path.extname(relativePath)); + const trackNo = metadata.common.track.no || 0; + const duration = Math.floor(metadata.format.duration || 0); + const mime = metadata.format.codec || "audio/mpeg"; + + // Artist and album info + // IMPORTANT: Prefer albumartist over artist to keep albums grouped under the primary artist + // This prevents featured artists from creating separate album entries + // e.g., "Artist A feat. Artist B" track should still be under "Artist A"'s album + let rawArtistName = + metadata.common.albumartist || + metadata.common.artist || + "Unknown Artist"; + + const albumTitle = metadata.common.album || "Unknown Album"; + const year = metadata.common.year || null; + + // ALWAYS extract primary artist first - this handles both: + // - Featured artists: "Artist A feat. Artist B" -> "Artist A" + // - Collaborations: "Artist A & Artist B" -> "Artist A" + // Band names like "Of Mice & Men" are preserved because extractPrimaryArtist + // only splits on " feat.", " ft.", " featuring ", " & ", etc. (with spaces) + const extractedPrimaryArtist = this.extractPrimaryArtist(rawArtistName); + let artistName = extractedPrimaryArtist; + + // Canonicalize Various Artists variations (VA, V.A., , etc.) + artistName = canonicalizeVariousArtists(artistName); + + // Try to find artist with the canonicalized name first + // This ensures "VA", "V.A.", etc. all find the canonical "Various Artists" + const normalizedPrimaryName = normalizeArtistName(artistName); + let artist = await prisma.artist.findFirst({ + where: { normalizedName: normalizedPrimaryName }, + }); + + // If no match with primary name and we actually extracted something, + // also try the full raw name (for bands like "Of Mice & Men") + if (!artist && extractedPrimaryArtist !== rawArtistName) { + const normalizedRawName = normalizeArtistName(rawArtistName); + artist = await prisma.artist.findFirst({ + where: { normalizedName: normalizedRawName }, + }); + // If full name matches an existing artist, use that instead + if (artist) { + artistName = rawArtistName; + } + } + + // Update normalized name for use below + const normalizedArtistName = normalizeArtistName(artistName); + + // If we found an artist, optionally update to better capitalization + if (artist && artist.name !== artistName) { + // Check if the new name has better capitalization (starts with uppercase) + const currentNameIsLowercase = artist.name[0] === artist.name[0].toLowerCase(); + const newNameIsCapitalized = artistName[0] === artistName[0].toUpperCase(); + + if (currentNameIsLowercase && newNameIsCapitalized) { + console.log(`Updating artist name capitalization: "${artist.name}" -> "${artistName}"`); + artist = await prisma.artist.update({ + where: { id: artist.id }, + data: { name: artistName }, + }); + } + } + + if (!artist) { + // Try fuzzy matching to catch typos like "the weeknd" vs "the weekend" + // Only check artists with similar normalized names (performance optimization) + const similarArtists = await prisma.artist.findMany({ + where: { + normalizedName: { + // Get artists whose normalized names start with similar prefix + startsWith: normalizedArtistName.substring(0, Math.min(3, normalizedArtistName.length)), + }, + }, + select: { id: true, name: true, normalizedName: true, mbid: true }, + }); + + // Check for fuzzy matches + for (const candidate of similarArtists) { + if (areArtistNamesSimilar(artistName, candidate.name, 95)) { + console.log(`Fuzzy match found: "${artistName}" -> "${candidate.name}"`); + artist = candidate; + break; + } + } + } + + if (!artist) { + // Try to find by MusicBrainz ID if available + const artistMbid = metadata.common.musicbrainz_artistid?.[0]; + if (artistMbid) { + artist = await prisma.artist.findUnique({ + where: { mbid: artistMbid }, + }); + + // If we have a real MBID but no artist exists, check if there's a temp artist we should consolidate + if (!artist) { + const tempArtist = await prisma.artist.findFirst({ + where: { + normalizedName: normalizedArtistName, + mbid: { startsWith: 'temp-' }, + }, + }); + + if (tempArtist) { + // Consolidate: update temp artist to real MBID + console.log(`[SCANNER] Consolidating temp artist "${tempArtist.name}" with real MBID: ${artistMbid}`); + artist = await prisma.artist.update({ + where: { id: tempArtist.id }, + data: { mbid: artistMbid }, + }); + } + } + } + + if (!artist) { + // Create new artist (use a temporary MBID for now) + artist = await prisma.artist.create({ + data: { + name: artistName, + normalizedName: normalizedArtistName, + mbid: + artistMbid || `temp-${Date.now()}-${Math.random()}`, + enrichmentStatus: "pending", + }, + }); + } + } + + // Get or create album + let album = await prisma.album.findFirst({ + where: { + artistId: artist.id, + title: albumTitle, + }, + }); + + if (!album) { + // Try to find by release group MBID if available + const albumMbid = metadata.common.musicbrainz_releasegroupid; + if (albumMbid) { + album = await prisma.album.findUnique({ + where: { rgMbid: albumMbid }, + }); + } + + if (!album) { + // Create new album (use a temporary MBID for now) + const rgMbid = + albumMbid || `temp-${Date.now()}-${Math.random()}`; + + // Determine if this is a discovery album: + // 1. Check file path (legacy: /music/discovery/ folder) + // 2. Check if artist+album matches a discovery download job + // 3. Check if artist is a discovery-only artist (has DISCOVER albums but no LIBRARY albums) + const isDiscoveryByPath = this.isDiscoveryPath(relativePath); + const isDiscoveryByJob = await this.isDiscoveryDownload(artistName, albumTitle); + + // Check if this artist is discovery-only (has no LIBRARY albums) + // If so, any new albums from them should also be DISCOVER + let isDiscoveryArtist = false; + if (!isDiscoveryByPath && !isDiscoveryByJob) { + const artistAlbums = await prisma.album.findMany({ + where: { artistId: artist.id }, + select: { location: true }, + }); + + // Artist is discovery-only if they have albums but NONE are LIBRARY + if (artistAlbums.length > 0) { + const hasLibraryAlbums = artistAlbums.some(a => a.location === "LIBRARY"); + isDiscoveryArtist = !hasLibraryAlbums; + if (isDiscoveryArtist) { + console.log(`[Scanner] Discovery-only artist detected: ${artistName}`); + } + } + } + + const isDiscoveryAlbum = isDiscoveryByPath || isDiscoveryByJob || isDiscoveryArtist; + + album = await prisma.album.create({ + data: { + title: albumTitle, + artistId: artist.id, + rgMbid, + year, + primaryType: "Album", + location: isDiscoveryAlbum ? "DISCOVER" : "LIBRARY", + }, + }); + + // Only create OwnedAlbum record for library albums (not discovery) + // Discovery albums are temporary and should not appear in the user's library + if (!isDiscoveryAlbum) { + await prisma.ownedAlbum.create({ + data: { + rgMbid, + artistId: artist.id, + source: "native_scan", + }, + }); + } + } + + // Extract cover art if we have an extractor + // Re-extract if: no cover, OR native cover file is missing + if (this.coverArtExtractor) { + let needsExtraction = !album.coverUrl; + + // Check if existing native cover file is missing + if (album.coverUrl?.startsWith("native:")) { + const nativePath = album.coverUrl.replace("native:", ""); + const coverCachePath = path.join( + path.dirname(absolutePath), + "..", + "..", + "cache", + "covers", + nativePath + ); + // Use the extractor's cache path instead + const extractorCachePath = path.join( + (this.coverArtExtractor as any).coverCachePath, + nativePath + ); + if (!fs.existsSync(extractorCachePath)) { + needsExtraction = true; + } + } + + if (needsExtraction) { + const coverPath = await this.coverArtExtractor.extractCoverArt( + absolutePath, + album.id + ); + if (coverPath) { + await prisma.album.update({ + where: { id: album.id }, + data: { coverUrl: `native:${coverPath}` }, + }); + } else { + // No embedded art, try fetching from Deezer + try { + const deezerCover = await deezerService.getAlbumCover( + artistName, + albumTitle + ); + if (deezerCover) { + await prisma.album.update({ + where: { id: album.id }, + data: { coverUrl: deezerCover }, + }); + } + } catch (error) { + // Silently fail - cover art is optional + } + } + } + } + } + + // Upsert track + await prisma.track.upsert({ + where: { filePath: relativePath }, + create: { + albumId: album.id, + title, + trackNo, + duration, + mime, + filePath: relativePath, + fileModified: stats.mtime, + fileSize: stats.size, + }, + update: { + albumId: album.id, + title, + trackNo, + duration, + mime, + fileModified: stats.mtime, + fileSize: stats.size, + }, + }); + } +} diff --git a/backend/src/services/musicbrainz.ts b/backend/src/services/musicbrainz.ts new file mode 100644 index 0000000..a1723ce --- /dev/null +++ b/backend/src/services/musicbrainz.ts @@ -0,0 +1,656 @@ +import axios, { AxiosInstance } from "axios"; +import { redisClient } from "../utils/redis"; +import { rateLimiter } from "./rateLimiter"; + +class MusicBrainzService { + private client: AxiosInstance; + + constructor() { + this.client = axios.create({ + baseURL: "https://musicbrainz.org/ws/2", + timeout: 10000, + headers: { + "User-Agent": + "Lidify/1.0.0 (https://github.com/Chevron7Locked/lidify)", + }, + }); + } + + private async cachedRequest( + cacheKey: string, + requestFn: () => Promise, + ttlSeconds = 2592000 // 30 days + ) { + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } catch (err) { + console.warn("Redis get error:", err); + } + + // Use global rate limiter instead of local rate limiting + const data = await rateLimiter.execute("musicbrainz", requestFn); + + try { + // Use shorter TTL for null results (1 hour) vs successful results (30 days) + // This allows retrying failed lookups sooner while still caching successes + const actualTtl = data === null ? 3600 : ttlSeconds; + await redisClient.setEx(cacheKey, actualTtl, JSON.stringify(data)); + } catch (err) { + console.warn("Redis set error:", err); + } + + return data; + } + + async searchArtist(query: string, limit = 10) { + const cacheKey = `mb:search:artist:${query}:${limit}`; + + return this.cachedRequest(cacheKey, async () => { + const response = await this.client.get("/artist", { + params: { + query, + limit, + fmt: "json", + }, + }); + return response.data.artists || []; + }); + } + + async getArtist(mbid: string, includes: string[] = ["url-rels", "tags"]) { + const cacheKey = `mb:artist:${mbid}:${includes.join(",")}`; + + return this.cachedRequest(cacheKey, async () => { + const response = await this.client.get(`/artist/${mbid}`, { + params: { + inc: includes.join("+"), + fmt: "json", + }, + }); + return response.data; + }); + } + + async getReleaseGroups( + artistMbid: string, + types: string[] = ["album", "ep"], + limit = 100 + ) { + const cacheKey = `mb:rg:${artistMbid}:${types.join(",")}:${limit}`; + + return this.cachedRequest(cacheKey, async () => { + const response = await this.client.get("/release-group", { + params: { + artist: artistMbid, + type: types.join("|"), + limit, + fmt: "json", + }, + }); + return response.data["release-groups"] || []; + }); + } + + async getReleaseGroup(rgMbid: string) { + const cacheKey = `mb:rg:${rgMbid}`; + + return this.cachedRequest(cacheKey, async () => { + const response = await this.client.get(`/release-group/${rgMbid}`, { + params: { + inc: "artist-credits+releases", + fmt: "json", + }, + }); + return response.data; + }); + } + + async getReleaseGroupDetails(rgMbid: string) { + const cacheKey = `mb:rg:details:${rgMbid}`; + + return this.cachedRequest(cacheKey, async () => { + const response = await this.client.get(`/release-group/${rgMbid}`, { + params: { + inc: "artist-credits+releases+labels", + fmt: "json", + }, + }); + return response.data; + }); + } + + async getRelease(releaseMbid: string) { + const cacheKey = `mb:release:${releaseMbid}`; + + return this.cachedRequest(cacheKey, async () => { + const response = await this.client.get(`/release/${releaseMbid}`, { + params: { + inc: "recordings+artist-credits+labels", + fmt: "json", + }, + }); + return response.data; + }); + } + + extractPrimaryArtist(artistCredits: any[]): string { + if (!artistCredits || artistCredits.length === 0) + return "Unknown Artist"; + return ( + artistCredits[0].name || + artistCredits[0].artist?.name || + "Unknown Artist" + ); + } + + /** + * Escape special characters for Lucene query syntax + * MusicBrainz uses Lucene, which requires escaping: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ / + */ + private escapeLucene(str: string): string { + return str.replace(/([+\-&|!(){}[\]^"~*?:\\/])/g, "\\$1"); + } + + /** + * Normalize album/artist names for better matching + * Removes common suffixes and cleans up the string + */ + private normalizeForSearch(str: string): string { + return ( + str + .replace(/\s*\([^)]*\)\s*/g, " ") // Remove parenthetical content + .replace(/\s*\[[^\]]*\]\s*/g, " ") // Remove bracketed content + // Remove "- YEAR Remaster", "- Remastered YEAR", "- Deluxe Edition", etc. + .replace( + /\s*-\s*(\d{4}\s+)?(deluxe|remastered|remaster|edition|version|expanded|bonus|explicit|clean|single|radio edit|remix|acoustic|live|mono|stereo)(\s+\d{4})?\s*(edition|version|mix)?\s*/gi, + " " + ) + // Also catch standalone year suffixes like "- 2011" + .replace(/\s*-\s*\d{4}\s*$/gi, " ") + .replace(/\s+/g, " ") + .trim() + ); + } + + /** + * Strip all punctuation from string for fuzzy matching + * Used as a fallback when normal search fails (e.g., "Do You Realize??") + */ + private stripPunctuation(str: string): string { + return str + .replace(/[^\w\s]/g, "") // Remove all non-word, non-space chars + .replace(/\s+/g, " ") + .trim(); + } + + /** + * Search for an album (release-group) by title and artist name + * Returns the first matching release group or null + * Uses multiple search strategies for better matching + */ + async searchAlbum( + albumTitle: string, + artistName: string + ): Promise<{ id: string; title: string } | null> { + const cacheKey = `mb:search:album:${artistName}:${albumTitle}`; + + return this.cachedRequest(cacheKey, async () => { + // Strategy 1: Exact match with escaped special characters + const escapedTitle = this.escapeLucene(albumTitle); + const escapedArtist = this.escapeLucene(artistName); + + try { + const query1 = `releasegroup:"${escapedTitle}" AND artist:"${escapedArtist}"`; + const response1 = await this.client.get("/release-group", { + params: { + query: query1, + limit: 5, + fmt: "json", + }, + }); + + const releaseGroups1 = response1.data["release-groups"] || []; + if (releaseGroups1.length > 0) { + return { + id: releaseGroups1[0].id, + title: releaseGroups1[0].title, + }; + } + } catch (e) { + // Continue to strategy 2 + } + + // Strategy 2: Normalized/cleaned title search + const normalizedTitle = this.normalizeForSearch(albumTitle); + const normalizedArtist = this.normalizeForSearch(artistName); + + if ( + normalizedTitle !== albumTitle || + normalizedArtist !== artistName + ) { + try { + const escapedNormTitle = this.escapeLucene(normalizedTitle); + const escapedNormArtist = + this.escapeLucene(normalizedArtist); + const query2 = `releasegroup:"${escapedNormTitle}" AND artist:"${escapedNormArtist}"`; + const response2 = await this.client.get("/release-group", { + params: { + query: query2, + limit: 5, + fmt: "json", + }, + }); + + const releaseGroups2 = + response2.data["release-groups"] || []; + if (releaseGroups2.length > 0) { + return { + id: releaseGroups2[0].id, + title: releaseGroups2[0].title, + }; + } + } catch (e) { + // Continue to strategy 3 + } + } + + // Strategy 3: Fuzzy search without quotes (last resort) + try { + // Use simple terms without quotes for fuzzy matching + const simpleTitle = normalizedTitle + .split(" ") + .slice(0, 3) + .join(" "); // First 3 words + const simpleArtist = normalizedArtist.split(" ")[0]; // First word of artist + const query3 = `${this.escapeLucene( + simpleTitle + )} AND artist:${this.escapeLucene(simpleArtist)}`; + + const response3 = await this.client.get("/release-group", { + params: { + query: query3, + limit: 10, + fmt: "json", + }, + }); + + const releaseGroups3 = response3.data["release-groups"] || []; + + // Find a match where the artist name contains our search term + for (const rg of releaseGroups3) { + const rgArtist = + rg["artist-credit"]?.[0]?.name || + rg["artist-credit"]?.[0]?.artist?.name || + ""; + if ( + rgArtist + .toLowerCase() + .includes(simpleArtist.toLowerCase()) + ) { + return { + id: rg.id, + title: rg.title, + }; + } + } + } catch (e) { + // All strategies failed + } + + return null; + }); + } + + /** + * Search for a recording (track) and return album information + * This is useful when we have artist + track title but not album name + * Returns the album (release group) that the track appears on + */ + async searchRecording( + trackTitle: string, + artistName: string + ): Promise<{ + albumName: string; + albumMbid: string; + artistMbid: string; + trackMbid: string; + } | null> { + const cacheKey = `mb:search:recording:${artistName}:${trackTitle}`; + + return this.cachedRequest(cacheKey, async () => { + try { + // Normalize track title first - removes "- 2011 Remaster", "(Radio Edit)", etc. + const normalizedTitle = this.normalizeForSearch(trackTitle); + const normalizedArtist = this.normalizeForSearch(artistName); + + // Search for recording by normalized track title and artist + const escapedTitle = this.escapeLucene(normalizedTitle); + const escapedArtist = this.escapeLucene(normalizedArtist); + + const query = `recording:"${escapedTitle}" AND artist:"${escapedArtist}"`; + + const response = await this.client.get("/recording", { + params: { + query, + limit: 50, // Need high limit because bootleg recordings often rank first + fmt: "json", + inc: "releases+release-groups+artists", + }, + }); + + const allRecordings = response.data.recordings || []; + + console.log( + `[MusicBrainz] Query: "${trackTitle}" by "${artistName}"` + ); + console.log( + `[MusicBrainz] Found ${allRecordings.length} total recordings` + ); + + // Log first 5 recordings for debugging + allRecordings.slice(0, 5).forEach((rec: any, i: number) => { + const disambig = rec.disambiguation || "(studio)"; + const releases = rec.releases || []; + const albumNames = releases + .slice(0, 2) + .map((r: any) => r["release-group"]?.title || "?") + .join(", "); + console.log( + ` ${i + 1}. [${disambig}] → ${ + albumNames || "(no albums)" + }` + ); + }); + + // Filter out live recordings - they have disambiguation like "live, 1995-07-28" + // We want the studio recording, not live versions + const recordings = allRecordings.filter((rec: any) => { + const disambig = (rec.disambiguation || "").toLowerCase(); + // Skip if disambiguation contains "live" or date patterns + if (disambig.includes("live")) return false; + if (disambig.match(/\d{4}[-‐]\d{2}[-‐]\d{2}/)) return false; + if (disambig.includes("demo")) return false; + if (disambig.includes("acoustic")) return false; + if (disambig.includes("remix")) return false; + return true; + }); + + console.log( + `[MusicBrainz] After filtering live/demo: ${recordings.length} studio recordings` + ); + + if (recordings.length === 0) { + // Try fuzzy search without quotes + const normalizedTitle = this.normalizeForSearch(trackTitle); + const normalizedArtist = + this.normalizeForSearch(artistName); + const fuzzyQuery = `${this.escapeLucene( + normalizedTitle + )} AND artist:${this.escapeLucene(normalizedArtist)}`; + + const fuzzyResponse = await this.client.get("/recording", { + params: { + query: fuzzyQuery, + limit: 10, + fmt: "json", + inc: "releases+release-groups+artists", + }, + }); + + const fuzzyRecordings = fuzzyResponse.data.recordings || []; + + // Find best match by checking artist name similarity + for (const rec of fuzzyRecordings) { + const recArtist = + rec["artist-credit"]?.[0]?.name || + rec["artist-credit"]?.[0]?.artist?.name || + ""; + if ( + recArtist + .toLowerCase() + .includes( + normalizedArtist.toLowerCase().split(" ")[0] + ) + ) { + const result = this.extractAlbumFromRecording(rec); + if (result) return result; // Only return if we found a good album + } + } + + // Strategy 3: Strip all punctuation (handles "Do You Realize??" etc.) + const strippedTitle = this.stripPunctuation(trackTitle); + const strippedArtist = this.stripPunctuation(artistName); + + if (strippedTitle !== normalizedTitle) { + console.log(`[MusicBrainz] Trying punctuation-stripped search: "${strippedTitle}" by ${strippedArtist}`); + + const strippedQuery = `${strippedTitle} AND artist:${strippedArtist}`; + const strippedResponse = await this.client.get("/recording", { + params: { + query: strippedQuery, + limit: 10, + fmt: "json", + inc: "releases+release-groups+artists", + }, + }); + + const strippedRecordings = strippedResponse.data.recordings || []; + console.log(`[MusicBrainz] Punctuation-stripped search found ${strippedRecordings.length} recordings`); + + for (const rec of strippedRecordings) { + const recArtist = + rec["artist-credit"]?.[0]?.name || + rec["artist-credit"]?.[0]?.artist?.name || + ""; + if ( + recArtist + .toLowerCase() + .includes(strippedArtist.toLowerCase().split(" ")[0]) + ) { + const result = this.extractAlbumFromRecording(rec); + if (result) { + console.log(`[MusicBrainz] ✓ Found via punctuation-stripped search: ${result.albumName}`); + return result; + } + } + } + } + + return null; + } + + // Try each recording until we find one with a good (non-bootleg) album + for (const rec of recordings) { + const disambig = rec.disambiguation || "(no disambiguation)"; + console.log(`[MusicBrainz] Trying recording: "${rec.title}" [${disambig}]`); + const result = this.extractAlbumFromRecording(rec, false); + if (result) { + console.log(`[MusicBrainz] ✓ Found album: "${result.albumName}" (MBID: ${result.albumMbid})`); + return result; // Found a good album + } else { + console.log(`[MusicBrainz] ✗ No valid album found for this recording`); + } + } + + // Fallback: Try again accepting Singles/EPs as last resort + console.log(`[MusicBrainz] No official albums found, trying to find Singles/EPs...`); + for (const rec of recordings) { + const result = this.extractAlbumFromRecording(rec, true); + if (result) { + console.log(`[MusicBrainz] ✓ Found Single/EP: "${result.albumName}" (MBID: ${result.albumMbid})`); + return result; + } + } + + // No good albums found in any recording + console.log( + `[MusicBrainz] No official albums or singles found for "${trackTitle}" by ${artistName} (checked ${recordings.length} recordings)` + ); + return null; + } catch (error: any) { + console.error( + "MusicBrainz recording search error:", + error.message + ); + return null; + } + }); + } + + /** + * Extract album information from a MusicBrainz recording result + * Prioritizes studio albums and filters out compilations, live albums, and bootlegs + * @param allowSingles - If true, accepts Singles/EPs as a fallback (lower threshold) + */ + private extractAlbumFromRecording(recording: any, allowSingles: boolean = false): { + albumName: string; + albumMbid: string; + artistMbid: string; + trackMbid: string; + } | null { + // Get artist MBID + const artistMbid = recording["artist-credit"]?.[0]?.artist?.id || ""; + const trackMbid = recording.id || ""; + + // Find the best release (prefer studio albums, avoid compilations/live/bootlegs) + const releases = recording.releases || []; + + if (releases.length === 0) { + return null; + } + + // Score each release to find the best one + const scoredReleases = releases.map((release: any) => { + const rg = release["release-group"]; + if (!rg?.id) return { release, score: -1000 }; + + let score = 0; + const primaryType = rg["primary-type"] || ""; + const secondaryTypes: string[] = rg["secondary-types"] || []; + const title = (rg.title || "").toLowerCase(); + + // Primary type scoring + if (primaryType === "Album") score += 100; + else if (primaryType === "EP") score += 50; + else if (primaryType === "Single") score += 25; + else score -= 50; // Unknown type + + // Heavy penalties for compilations, live, bootlegs, soundtracks + if (secondaryTypes.includes("Compilation")) score -= 200; + if (secondaryTypes.includes("Live")) score -= 150; + if (secondaryTypes.includes("Remix")) score -= 100; + if (secondaryTypes.includes("DJ-mix")) score -= 200; + if (secondaryTypes.includes("Mixtape/Street")) score -= 100; + if (secondaryTypes.includes("Soundtrack")) score -= 150; // Movie/TV soundtracks + + // Title-based penalties (catch bootlegs and compilations missed by types) + if (title.match(/\d{4}[-‐]\d{2}[-‐]\d{2}/)) score -= 300; // Dates like "2006-03-11" = bootleg + if (title.includes("live at") || title.includes("live from")) + score -= 150; + if (title.includes("best of") || title.includes("greatest hits")) + score -= 200; + if (title.includes("compilation") || title.includes("collection")) + score -= 200; + if (title.includes("soundtrack")) score -= 100; + if (title.includes("various artists")) score -= 300; + if (title.includes("sounds of the")) score -= 200; // "Sounds of the 70s" etc. + if (title.includes("deep sounds")) score -= 200; + + // Bonus for official status + if (release.status === "Official") score += 20; + + return { release, score }; + }); + + // Sort by score (highest first) + scoredReleases.sort((a: any, b: any) => b.score - a.score); + + // Find the first release with a GOOD score + // Normal mode: score > 50 (studio album = 100+, EP = 50+) + // Allow singles mode: score > 0 (Single = 25+, excludes compilations with negative scores) + const threshold = allowSingles ? 0 : 50; + const bestResult = scoredReleases.find((r: any) => r.score > threshold); + + if (!bestResult) { + // No good releases found with this threshold - return null so we try the next recording + const modeText = allowSingles ? "singles" : "albums"; + const topScores = scoredReleases.slice(0, 3).map((r: any) => { + const title = + r.release["release-group"]?.title || r.release.title; + return `"${title}" (${r.score})`; + }); + console.log( + `[MusicBrainz] Skipping recording - no ${modeText} found in ${ + releases.length + } releases (threshold: ${threshold}). Top scores: ${topScores.join(", ")}` + ); + return null; + } + + const bestRelease = bestResult.release; + const releaseGroup = bestRelease["release-group"]; + + if (!releaseGroup?.id) { + return null; + } + + console.log( + `[MusicBrainz] Selected "${releaseGroup.title}" (score: ${bestResult.score}) from ${releases.length} releases` + ); + + return { + albumName: + releaseGroup.title || bestRelease.title || "Unknown Album", + albumMbid: releaseGroup.id, + artistMbid, + trackMbid, + }; + } + + /** + * Clear cached recording search result + * Useful for retrying failed lookups + */ + async clearRecordingCache(trackTitle: string, artistName: string): Promise { + const cacheKey = `mb:search:recording:${artistName}:${trackTitle}`; + try { + await redisClient.del(cacheKey); + console.log(`[MusicBrainz] Cleared cache for: "${trackTitle}" by ${artistName}`); + return true; + } catch (err) { + console.warn("Redis del error:", err); + return false; + } + } + + /** + * Clear all stale null cache entries for recording searches + * Returns the number of entries cleared + */ + async clearStaleRecordingCaches(): Promise { + try { + // Get all recording cache keys + const keys = await redisClient.keys("mb:search:recording:*"); + let cleared = 0; + + for (const key of keys) { + const value = await redisClient.get(key); + if (value === "null") { + await redisClient.del(key); + cleared++; + } + } + + console.log(`[MusicBrainz] Cleared ${cleared} stale null cache entries`); + return cleared; + } catch (err) { + console.error("Error clearing stale caches:", err); + return 0; + } + } +} + +export const musicBrainzService = new MusicBrainzService(); diff --git a/backend/src/services/notificationService.ts b/backend/src/services/notificationService.ts new file mode 100644 index 0000000..d9c668b --- /dev/null +++ b/backend/src/services/notificationService.ts @@ -0,0 +1,225 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export type NotificationType = + | "system" + | "download_complete" + | "download_failed" + | "playlist_ready" + | "import_complete" + | "error"; + +export interface CreateNotificationParams { + userId: string; + type: NotificationType; + title: string; + message?: string; + metadata?: Record; +} + +class NotificationService { + /** + * Create a new notification for a user + */ + async create(params: CreateNotificationParams) { + const { userId, type, title, message, metadata } = params; + + const notification = await prisma.notification.create({ + data: { + userId, + type, + title, + message, + metadata, + }, + }); + + console.log( + `[NOTIFICATION] Created: ${type} - ${title} for user ${userId}` + ); + return notification; + } + + /** + * Get all uncleared notifications for a user + */ + async getForUser(userId: string, includeRead = true) { + return prisma.notification.findMany({ + where: { + userId, + cleared: false, + ...(includeRead ? {} : { read: false }), + }, + orderBy: { createdAt: "desc" }, + take: 100, + }); + } + + /** + * Get unread count for a user + */ + async getUnreadCount(userId: string) { + return prisma.notification.count({ + where: { + userId, + cleared: false, + read: false, + }, + }); + } + + /** + * Mark a notification as read + */ + async markAsRead(id: string, userId: string) { + return prisma.notification.updateMany({ + where: { id, userId }, + data: { read: true }, + }); + } + + /** + * Mark all notifications as read for a user + */ + async markAllAsRead(userId: string) { + return prisma.notification.updateMany({ + where: { userId, cleared: false }, + data: { read: true }, + }); + } + + /** + * Clear a notification (remove from view but keep in DB) + */ + async clear(id: string, userId: string) { + return prisma.notification.updateMany({ + where: { id, userId }, + data: { cleared: true }, + }); + } + + /** + * Clear all notifications for a user + */ + async clearAll(userId: string) { + return prisma.notification.updateMany({ + where: { userId }, + data: { cleared: true }, + }); + } + + /** + * Delete old cleared notifications (cleanup job) + */ + async deleteOldCleared(daysOld = 30) { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - daysOld); + + const result = await prisma.notification.deleteMany({ + where: { + cleared: true, + createdAt: { lt: cutoff }, + }, + }); + + if (result.count > 0) { + console.log( + `[NOTIFICATION] Cleaned up ${result.count} old notifications` + ); + } + return result; + } + + // === Convenience methods for common notification types === + + /** + * Notify user that a download completed + */ + async notifyDownloadComplete( + userId: string, + subject: string, + albumId?: string, + artistId?: string + ) { + return this.create({ + userId, + type: "download_complete", + title: "Download Complete", + message: `${subject} has been downloaded and added to your library`, + metadata: { albumId, artistId }, + }); + } + + /** + * Notify user that a download failed + */ + async notifyDownloadFailed( + userId: string, + subject: string, + error?: string + ) { + return this.create({ + userId, + type: "download_failed", + title: "Download Failed", + message: `Failed to download ${subject}${ + error ? `: ${error}` : "" + }`, + metadata: { subject, error }, + }); + } + + /** + * Notify user that a playlist is ready + */ + async notifyPlaylistReady( + userId: string, + playlistName: string, + playlistId: string, + trackCount: number + ) { + return this.create({ + userId, + type: "playlist_ready", + title: "Playlist Ready", + message: `"${playlistName}" is ready with ${trackCount} tracks`, + metadata: { playlistId, playlistName, trackCount }, + }); + } + + /** + * Notify user that a Spotify import completed + */ + async notifyImportComplete( + userId: string, + playlistName: string, + playlistId: string, + matchedTracks: number, + totalTracks: number + ) { + const message = `"${playlistName}" imported with ${matchedTracks} of ${totalTracks} tracks`; + + return this.create({ + userId, + type: "import_complete", + title: "Import Complete", + message, + metadata: { playlistId, playlistName, matchedTracks, totalTracks }, + }); + } + + /** + * System notification (cache cleared, sync complete, etc.) + */ + async notifySystem(userId: string, title: string, message?: string) { + return this.create({ + userId, + type: "system", + title, + message, + }); + } +} + +export const notificationService = new NotificationService(); diff --git a/backend/src/services/openai.ts b/backend/src/services/openai.ts new file mode 100644 index 0000000..bdd0442 --- /dev/null +++ b/backend/src/services/openai.ts @@ -0,0 +1,184 @@ +import axios, { AxiosInstance } from "axios"; +import { config } from "../config"; + +interface PlaylistTrack { + artistName: string; + albumTitle?: string; + trackTitle: string; + reason?: string; +} + +interface GeneratePlaylistParams { + userId: string; + topArtists: Array<{ name: string; playCount: number; genres: string[] }>; + recentDiscoveries: string[]; + likedArtists: string[]; + dislikedArtists: string[]; + targetCount: number; +} + +class OpenAIService { + private client: AxiosInstance; + private apiKey: string; + + constructor() { + this.apiKey = config.openai.apiKey; + this.client = axios.create({ + baseURL: "https://api.openai.com/v1", + timeout: 60000, + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + }); + } + + async generateWeeklyPlaylist( + params: GeneratePlaylistParams + ): Promise { + const { + topArtists, + recentDiscoveries, + likedArtists, + dislikedArtists, + targetCount, + } = params; + + // Build context for AI + const topArtistsText = topArtists + .slice(0, 20) + .map( + (a) => + `${a.name} (${a.playCount} plays, genres: ${a.genres.join( + ", " + )})` + ) + .join("\n"); + + const prompt = `You are a music curator creating a personalized "Discover Weekly" playlist. + +USER'S LISTENING PROFILE: +Top Artists (last 90 days): +${topArtistsText} + +Recent Discoveries (NEW artists to explore): ${recentDiscoveries.join(", ") || "None yet"} +Liked Artists: ${likedArtists.join(", ") || "None"} +Disliked Artists (NEVER recommend): ${dislikedArtists.join(", ") || "None"} + +TASK: +Generate a ${targetCount}-track playlist with this breakdown: +- 25% (${Math.round( + targetCount * 0.25 + )} tracks): From the user's top artists (1-2 tracks max per artist) +- 75% (${Math.round( + targetCount * 0.75 + )} tracks): NEW discoveries from the "Recent Discoveries" list above + +CRITICAL REQUIREMENTS: +1. PRIORITIZE new artists from the "Recent Discoveries" list - this is the main goal +2. Include only 1-2 well-known tracks from the user's top artists as "familiar anchors" +3. For new discoveries, choose popular, accessible tracks that will hook the listener +4. Maintain genre consistency with user's preferences +5. NEVER include artists from the "Disliked Artists" list +6. Variety of moods and tempos across the playlist + +OUTPUT FORMAT (JSON): +{ + "tracks": [ + { + "artistName": "Artist Name", + "trackTitle": "Track Title", + "reason": "Brief reason (e.g., 'Popular track from your favorite artist' or 'Similar to Jamiroquai')" + } + ] +} + +Return ONLY valid JSON, no markdown formatting.`; + + try { + const response = await this.client.post("/chat/completions", { + model: "gpt-4-turbo", + messages: [ + { + role: "system", + content: + "You are an expert music curator who creates personalized playlists based on listening history. You always respond with valid JSON only. Ensure all strings are properly escaped.", + }, + { + role: "user", + content: prompt, + }, + ], + max_tokens: 2000, + temperature: 0.7, + response_format: { type: "json_object" }, + }); + + const content = response.data.choices[0].message.content.trim(); + + // Remove markdown code blocks if present + let jsonContent = content; + if (content.startsWith("```json")) { + jsonContent = content + .replace(/```json\n?/g, "") + .replace(/```\n?/g, "") + .trim(); + } else if (content.startsWith("```")) { + jsonContent = content.replace(/```\n?/g, "").trim(); + } + + const result = JSON.parse(jsonContent); + + return result.tracks || []; + } catch (error: any) { + console.error( + "OpenAI API error:", + error.response?.data || error.message + ); + + // Log the raw response content for debugging + if (error instanceof SyntaxError) { + console.error("Failed to parse JSON response"); + } + + throw new Error("Failed to generate playlist with AI"); + } + } + + async enhanceTrackRecommendation( + track: { artist: string; title: string }, + userContext: string + ): Promise { + const prompt = `Given this track: "${track.title}" by ${track.artist} +User context: ${userContext} + +Provide a single-sentence reason why this track would fit in their Discover Weekly playlist. +Be concise and engaging (max 15 words).`; + + try { + const response = await this.client.post("/chat/completions", { + model: "gpt-3.5-turbo", + messages: [ + { + role: "system", + content: + "You write brief, engaging music recommendations.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 50, + }); + + return response.data.choices[0].message.content.trim(); + } catch (error) { + console.error("OpenAI enhancement error:", error); + return "Recommended based on your listening history"; + } + } +} + +export const openAIService = new OpenAIService(); diff --git a/backend/src/services/podcastCache.ts b/backend/src/services/podcastCache.ts new file mode 100644 index 0000000..8ccd024 --- /dev/null +++ b/backend/src/services/podcastCache.ts @@ -0,0 +1,252 @@ +import { prisma } from "../utils/db"; +import fs from "fs/promises"; +import path from "path"; +import { config } from "../config"; + +/** + * Service to cache podcast cover images locally + * Podcasts are already stored in database (from RSS feeds) + * This service adds cover image caching to avoid repeated downloads + */ + +interface CoverSyncResult { + synced: number; + failed: number; + skipped: number; + errors: string[]; +} + +export class PodcastCacheService { + private coverCacheDir: string; + + constructor() { + // Store covers in: /cover-cache/podcasts/ + this.coverCacheDir = path.join( + config.music.musicPath, + "cover-cache", + "podcasts" + ); + } + + /** + * Sync cover images for all podcasts + */ + async syncAllCovers(): Promise { + const result: CoverSyncResult = { + synced: 0, + failed: 0, + skipped: 0, + errors: [], + }; + + try { + console.log(" Starting podcast cover sync..."); + + // Ensure cover cache directory exists + await fs.mkdir(this.coverCacheDir, { recursive: true }); + + // Fetch all podcasts from database + const podcasts = await prisma.podcast.findMany({ + where: { + localCoverPath: null, // Only sync podcasts without local covers + imageUrl: { not: null }, + }, + }); + + console.log( + `[PODCAST] Found ${podcasts.length} podcasts needing cover sync` + ); + + for (const podcast of podcasts) { + try { + if (podcast.imageUrl) { + const localPath = await this.downloadCover( + podcast.id, + podcast.imageUrl, + "podcast" + ); + + if (localPath) { + await prisma.podcast.update({ + where: { id: podcast.id }, + data: { localCoverPath: localPath }, + }); + result.synced++; + console.log(` Synced cover for: ${podcast.title}`); + } else { + result.skipped++; + } + } + } catch (error: any) { + result.failed++; + const errorMsg = `Failed to sync cover for ${podcast.title}: ${error.message}`; + result.errors.push(errorMsg); + console.error(` ✗ ${errorMsg}`); + } + } + + console.log("\nPodcast Cover Sync Summary:"); + console.log(` Synced: ${result.synced}`); + console.log(` Failed: ${result.failed}`); + console.log(` Skipped: ${result.skipped}`); + + return result; + } catch (error: any) { + console.error(" Podcast cover sync failed:", error); + throw error; + } + } + + /** + * Sync cover images for all podcast episodes (if they have unique covers) + */ + async syncEpisodeCovers(): Promise { + const result: CoverSyncResult = { + synced: 0, + failed: 0, + skipped: 0, + errors: [], + }; + + try { + console.log(" Starting podcast episode cover sync..."); + + await fs.mkdir(this.coverCacheDir, { recursive: true }); + + // Fetch episodes with unique covers (different from podcast cover) + const episodes = await prisma.podcastEpisode.findMany({ + where: { + localCoverPath: null, + imageUrl: { not: null }, + }, + include: { + podcast: { + select: { + imageUrl: true, + }, + }, + }, + }); + + // Filter to only episodes with unique covers + const uniqueEpisodes = episodes.filter( + (ep) => ep.imageUrl !== ep.podcast.imageUrl + ); + + console.log( + `[PODCAST] Found ${uniqueEpisodes.length} episodes with unique covers` + ); + + for (const episode of uniqueEpisodes) { + try { + if (episode.imageUrl) { + const localPath = await this.downloadCover( + episode.id, + episode.imageUrl, + "episode" + ); + + if (localPath) { + await prisma.podcastEpisode.update({ + where: { id: episode.id }, + data: { localCoverPath: localPath }, + }); + result.synced++; + console.log( + ` Synced cover for episode: ${episode.title}` + ); + } else { + result.skipped++; + } + } + } catch (error: any) { + result.failed++; + const errorMsg = `Failed to sync cover for episode ${episode.title}: ${error.message}`; + result.errors.push(errorMsg); + console.error(` ✗ ${errorMsg}`); + } + } + + console.log("\nEpisode Cover Sync Summary:"); + console.log(` Synced: ${result.synced}`); + console.log(` Failed: ${result.failed}`); + console.log(` Skipped: ${result.skipped}`); + + return result; + } catch (error: any) { + console.error(" Episode cover sync failed:", error); + throw error; + } + } + + /** + * Download a cover image and save it locally + */ + private async downloadCover( + id: string, + imageUrl: string, + type: "podcast" | "episode" + ): Promise { + try { + const response = await fetch(imageUrl); + + if (!response.ok) { + throw new Error( + `HTTP ${response.status}: ${response.statusText}` + ); + } + + const buffer = await response.arrayBuffer(); + const fileName = `${type}_${id}.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 ${type} ${id}:`, + error.message + ); + return null; + } + } + + /** + * Clean up orphaned covers + */ + async cleanupOrphanedCovers(): Promise { + const podcasts = await prisma.podcast.findMany({ + select: { localCoverPath: true }, + }); + + const episodes = await prisma.podcastEpisode.findMany({ + select: { localCoverPath: true }, + }); + + const validCoverPaths = new Set([ + ...podcasts + .filter((p) => p.localCoverPath) + .map((p) => path.basename(p.localCoverPath!)), + ...episodes + .filter((e) => e.localCoverPath) + .map((e) => path.basename(e.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 podcast cover: ${file}`); + } + } + + return deleted; + } +} + +// Export singleton instance +export const podcastCacheService = new PodcastCacheService(); diff --git a/backend/src/services/podcastDownload.ts b/backend/src/services/podcastDownload.ts new file mode 100644 index 0000000..1bc0380 --- /dev/null +++ b/backend/src/services/podcastDownload.ts @@ -0,0 +1,441 @@ +import { prisma } from "../utils/db"; +import { config } from "../config"; +import fs from "fs/promises"; +import path from "path"; +import axios from "axios"; + +/** + * PodcastDownloadService - Background download and caching of podcast episodes + * + * Features: + * - Non-blocking background downloads when episodes are played + * - 30-day cache expiry with automatic cleanup + * - Proper range request support for cached files + */ + +// Track in-progress downloads to avoid duplicates +const downloadingEpisodes = new Set(); + +// Track download progress (episodeId -> { bytesDownloaded, totalBytes }) +interface DownloadProgress { + bytesDownloaded: number; + totalBytes: number; +} +const downloadProgress = new Map(); + +// Cache directory for podcast audio files +const getPodcastCacheDir = (): string => { + return path.join(config.music.transcodeCachePath, "../podcast-audio"); +}; + +/** + * Get download progress for an episode + * Returns { progress: 0-100, downloading: boolean } or null if not downloading + */ +export function getDownloadProgress(episodeId: string): { progress: number; downloading: boolean } | null { + if (!downloadingEpisodes.has(episodeId)) { + return null; + } + + const progress = downloadProgress.get(episodeId); + if (!progress || progress.totalBytes === 0) { + return { progress: 0, downloading: true }; + } + + const percent = Math.round((progress.bytesDownloaded / progress.totalBytes) * 100); + return { progress: Math.min(100, percent), downloading: true }; +} + +/** + * Check if a cached file exists and is valid + * Returns null if file doesn't exist, is empty, or is still being downloaded + */ +export async function getCachedFilePath(episodeId: string): Promise { + // Don't return cache path if still downloading - file may be incomplete + if (downloadingEpisodes.has(episodeId)) { + console.log(`[PODCAST-DL] Episode ${episodeId} is still downloading, not using cache`); + return null; + } + + const cacheDir = getPodcastCacheDir(); + const cachedPath = path.join(cacheDir, `${episodeId}.mp3`); + + try { + await fs.access(cachedPath, fs.constants.F_OK); + const stats = await fs.stat(cachedPath); + + // File must be > 0 bytes to be valid + if (stats.size > 0) { + // Strong validation: if we know the canonical remote file size, require the cache to match. + // This prevents "cached=true" when we only downloaded part of the file (which breaks seeking and causes 416s). + try { + const episode = await prisma.podcastEpisode.findUnique({ + where: { id: episodeId }, + select: { fileSize: true }, + }); + if (episode?.fileSize && episode.fileSize > 0) { + const expected = episode.fileSize; + const actual = stats.size; + const variance = Math.abs(actual - expected) / expected; + if (variance > 0.01) { + console.log( + `[PODCAST-DL] Episode size mismatch vs episode.fileSize for ${episodeId}: actual ${actual} vs expected ${expected} (variance ${Math.round( + variance * 100 + )}%), deleting cache` + ); + await fs.unlink(cachedPath).catch(() => {}); + await prisma.podcastDownload.deleteMany({ + where: { episodeId }, + }); + return null; + } + } + } catch { + // If this check fails, fall back to prior DB-record based validation + } + + // Check database record exists + const dbRecord = await prisma.podcastDownload.findFirst({ + where: { episodeId } + }); + + // If no DB record, file might be incomplete or stale + if (!dbRecord) { + console.log(`[PODCAST-DL] No DB record for ${episodeId}, deleting stale cache file`); + await fs.unlink(cachedPath).catch(() => {}); + return null; + } + + // Validate file size matches what we recorded (allow 1% variance for filesystem differences) + const expectedSize = dbRecord.fileSizeMb * 1024 * 1024; + const actualSize = stats.size; + const variance = Math.abs(actualSize - expectedSize) / expectedSize; + + if (expectedSize > 0 && variance > 0.01) { + console.log(`[PODCAST-DL] Size mismatch for ${episodeId}: actual ${actualSize} vs expected ${Math.round(expectedSize)}, deleting`); + await fs.unlink(cachedPath).catch(() => {}); + await prisma.podcastDownload.deleteMany({ where: { episodeId } }); + return null; + } + + // Update last accessed time + await prisma.podcastDownload.updateMany({ + where: { episodeId }, + data: { lastAccessedAt: new Date() } + }); + + console.log(`[PODCAST-DL] Cache valid for ${episodeId}: ${stats.size} bytes`); + return cachedPath; + } + return null; + } catch { + return null; + } +} + +/** + * Start a background download for an episode + * Returns immediately, download happens asynchronously + */ +export function downloadInBackground( + episodeId: string, + audioUrl: string, + userId: string +): void { + // Skip if already downloading + if (downloadingEpisodes.has(episodeId)) { + console.log(`[PODCAST-DL] Already downloading episode ${episodeId}, skipping`); + return; + } + + // Mark as downloading + downloadingEpisodes.add(episodeId); + + // Start download in background (don't await) + performDownload(episodeId, audioUrl, userId) + .catch(err => { + console.error(`[PODCAST-DL] Background download failed for ${episodeId}:`, err.message); + }) + .finally(() => { + downloadingEpisodes.delete(episodeId); + }); +} + +/** + * Perform the actual download with retry support + */ +async function performDownload( + episodeId: string, + audioUrl: string, + userId: string, + attempt: number = 1 +): Promise { + const maxAttempts = 3; + console.log(`[PODCAST-DL] Starting background download for episode ${episodeId} (attempt ${attempt}/${maxAttempts})`); + + const cacheDir = getPodcastCacheDir(); + + // Ensure cache directory exists + await fs.mkdir(cacheDir, { recursive: true }); + + const tempPath = path.join(cacheDir, `${episodeId}.tmp`); + const finalPath = path.join(cacheDir, `${episodeId}.mp3`); + + try { + // Check if already cached (and validated) + downloadingEpisodes.delete(episodeId); // Temporarily remove to check cache + const existingCached = await getCachedFilePath(episodeId); + downloadingEpisodes.add(episodeId); // Re-add + if (existingCached) { + console.log(`[PODCAST-DL] Episode ${episodeId} already cached, skipping download`); + return; + } + + // Clean up any partial temp files from previous attempts + await fs.unlink(tempPath).catch(() => {}); + + // Download the file with longer timeout for large podcasts + const response = await axios.get(audioUrl, { + responseType: 'stream', + timeout: 600000, // 10 minute timeout for large files (3+ hour podcasts) + headers: { + 'User-Agent': 'Lidify/1.0 (https://github.com/Chevron7Locked/lidify)' + }, + // Don't let axios decompress - we want raw bytes + decompress: false + }); + + const contentLength = parseInt(response.headers["content-length"] || "0", 10); + let expectedBytes = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0; + + // If the origin provides Content-Length, treat it as ground truth and persist it. + // This prevents us from "accepting" partial caches that later break seeking. + if (expectedBytes > 0) { + try { + const episode = await prisma.podcastEpisode.findUnique({ + where: { id: episodeId }, + select: { fileSize: true }, + }); + const existing = episode?.fileSize || 0; + if (!existing) { + await prisma.podcastEpisode.update({ + where: { id: episodeId }, + data: { fileSize: expectedBytes }, + }); + } else { + const variance = Math.abs(existing - expectedBytes) / existing; + if (variance > 0.01) { + await prisma.podcastEpisode.update({ + where: { id: episodeId }, + data: { fileSize: expectedBytes }, + }); + } + } + } catch { + // Non-fatal + } + } else { + // Fallback: use DB fileSize if present (better than nothing) + try { + const episode = await prisma.podcastEpisode.findUnique({ + where: { id: episodeId }, + select: { fileSize: true }, + }); + if (episode?.fileSize && episode.fileSize > 0) { + expectedBytes = episode.fileSize; + } + } catch {} + } + + console.log( + `[PODCAST-DL] Downloading ${episodeId} (${expectedBytes > 0 ? Math.round(expectedBytes / 1024 / 1024) : 0}MB)` + ); + + // Initialize progress tracking + downloadProgress.set(episodeId, { + bytesDownloaded: 0, + totalBytes: expectedBytes || 0, + }); + + // Write to temp file first with progress tracking + const writeStream = (await import('fs')).createWriteStream(tempPath); + let bytesDownloaded = 0; + let lastLogTime = Date.now(); + + await new Promise((resolve, reject) => { + response.data.on('data', (chunk: Buffer) => { + bytesDownloaded += chunk.length; + downloadProgress.set(episodeId, { bytesDownloaded, totalBytes: contentLength }); + + // Log progress every 30 seconds for long downloads + const now = Date.now(); + if (now - lastLogTime > 30000) { + const percent = contentLength > 0 ? Math.round((bytesDownloaded / contentLength) * 100) : 0; + console.log(`[PODCAST-DL] Download progress ${episodeId}: ${percent}% (${Math.round(bytesDownloaded / 1024 / 1024)}MB)`); + lastLogTime = now; + } + }); + + response.data.on('end', () => { + writeStream.end(() => resolve()); + }); + + response.data.pipe(writeStream, { end: false }); + + writeStream.on('error', (err) => { + response.data.destroy(); + reject(err); + }); + + response.data.on('error', (err: Error) => { + writeStream.destroy(); + reject(err); + }); + + // Handle aborted connections + response.data.on('aborted', () => { + writeStream.destroy(); + reject(new Error('Download aborted by server')); + }); + }); + + // Verify file was written and is complete + const stats = await fs.stat(tempPath); + if (stats.size === 0) { + await fs.unlink(tempPath).catch(() => {}); + throw new Error('Downloaded file is empty'); + } + + // Check completeness when we know an expected size (prefer Content-Length). + // Allow a small variance because some servers are inconsistent at the byte level. + if (expectedBytes > 0) { + const variance = Math.abs(stats.size - expectedBytes) / expectedBytes; + if (variance > 0.01) { + const percentComplete = Math.round((stats.size / expectedBytes) * 100); + console.error(`[PODCAST-DL] Incomplete download for ${episodeId}: ${stats.size}/${expectedBytes} bytes (${percentComplete}%)`); + await fs.unlink(tempPath).catch(() => {}); + throw new Error(`Download incomplete: got ${stats.size} bytes, expected ${expectedBytes}`); + } + } + + // Move temp file to final location + await fs.rename(tempPath, finalPath); + + // Record in database + const fileSizeMb = stats.size / 1024 / 1024; + + await prisma.podcastDownload.upsert({ + where: { + userId_episodeId: { userId, episodeId } + }, + create: { + userId, + episodeId, + localPath: finalPath, + fileSizeMb, + downloadedAt: new Date(), + lastAccessedAt: new Date() + }, + update: { + localPath: finalPath, + fileSizeMb, + downloadedAt: new Date(), + lastAccessedAt: new Date() + } + }); + + console.log(`[PODCAST-DL] Successfully cached episode ${episodeId} (${fileSizeMb.toFixed(1)}MB)`); + + // Clean up progress tracking + downloadProgress.delete(episodeId); + + } catch (error: any) { + // Clean up temp file and progress tracking on error + await fs.unlink(tempPath).catch(() => {}); + downloadProgress.delete(episodeId); + + // Retry on failure + if (attempt < maxAttempts) { + console.log(`[PODCAST-DL] Download failed (attempt ${attempt}), retrying in 5s: ${error.message}`); + await new Promise(resolve => setTimeout(resolve, 5000)); + return performDownload(episodeId, audioUrl, userId, attempt + 1); + } + + throw error; + } +} + +/** + * Clean up cached episodes older than 30 days + * Should be called periodically (e.g., daily) + */ +export async function cleanupExpiredCache(): Promise<{ deleted: number; freedMb: number }> { + console.log('[PODCAST-DL] Starting cache cleanup...'); + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // Find expired downloads + const expiredDownloads = await prisma.podcastDownload.findMany({ + where: { + lastAccessedAt: { lt: thirtyDaysAgo } + } + }); + + let deleted = 0; + let freedMb = 0; + + for (const download of expiredDownloads) { + try { + // Delete file from disk + await fs.unlink(download.localPath).catch(() => {}); + + // Delete database record + await prisma.podcastDownload.delete({ + where: { id: download.id } + }); + + deleted++; + freedMb += download.fileSizeMb; + + console.log(`[PODCAST-DL] Deleted expired cache: ${path.basename(download.localPath)}`); + } catch (err: any) { + console.error(`[PODCAST-DL] Failed to delete ${download.localPath}:`, err.message); + } + } + + console.log(`[PODCAST-DL] Cleanup complete: ${deleted} files deleted, ${freedMb.toFixed(1)}MB freed`); + + return { deleted, freedMb }; +} + +/** + * Get cache statistics + */ +export async function getCacheStats(): Promise<{ + totalFiles: number; + totalSizeMb: number; + oldestFile: Date | null; +}> { + const downloads = await prisma.podcastDownload.findMany({ + select: { + fileSizeMb: true, + downloadedAt: true + }, + orderBy: { downloadedAt: 'asc' } + }); + + return { + totalFiles: downloads.length, + totalSizeMb: downloads.reduce((sum, d) => sum + d.fileSizeMb, 0), + oldestFile: downloads.length > 0 ? downloads[0].downloadedAt : null + }; +} + +/** + * Check if an episode is currently being downloaded + */ +export function isDownloading(episodeId: string): boolean { + return downloadingEpisodes.has(episodeId); +} + diff --git a/backend/src/services/podcastindex.ts b/backend/src/services/podcastindex.ts new file mode 100644 index 0000000..e0be9fd --- /dev/null +++ b/backend/src/services/podcastindex.ts @@ -0,0 +1,94 @@ +import { getSystemSettings } from "../utils/systemSettings"; +import { decrypt } from "../utils/encryption"; + +let podcastindexApi: any = null; + +/** + * Initialize PodcastIndex API client with credentials from system settings + */ +async function initPodcastindexClient() { + const settings = await getSystemSettings(); + + if (!settings?.podcastindexEnabled) { + throw new Error("PodcastIndex is not enabled in system settings"); + } + + if (!settings.podcastindexApiKey || !settings.podcastindexApiSecret) { + throw new Error("PodcastIndex API credentials not configured"); + } + + const apiKey = decrypt(settings.podcastindexApiKey); + const apiSecret = decrypt(settings.podcastindexApiSecret); + + const podcastIndexApi = require("podcast-index-api"); + podcastindexApi = podcastIndexApi(apiKey, apiSecret, "Lidify"); + + return podcastindexApi; +} + +/** + * Search podcasts by term + */ +export async function searchPodcasts(query: string, max: number = 20) { + const client = await initPodcastindexClient(); + const results = await client.searchByTerm(query, max); + return results; +} + +/** + * Get trending podcasts + */ +export async function getTrendingPodcasts(max: number = 10, category?: string) { + const client = await initPodcastindexClient(); + const results = await client.podcastsTrending(max, null, null, category); + return results; +} + +/** + * Get podcasts by category + */ +export async function getPodcastsByCategory( + category: string, + max: number = 20 +) { + const client = await initPodcastindexClient(); + const results = await client.searchByTerm("", max, null, null); + // Filter by category + return results; +} + +/** + * Get all categories + */ +export async function getCategories() { + const client = await initPodcastindexClient(); + const results = await client.categoriesList(); + return results; +} + +/** + * Get podcast by feed URL + */ +export async function getPodcastByFeedUrl(feedUrl: string) { + const client = await initPodcastindexClient(); + const results = await client.podcastsByFeedUrl(feedUrl); + return results; +} + +/** + * Get podcast by iTunes ID + */ +export async function getPodcastByItunesId(itunesId: string) { + const client = await initPodcastindexClient(); + const results = await client.podcastsByFeedItunesId(itunesId); + return results; +} + +/** + * Get recent podcasts + */ +export async function getRecentPodcasts(max: number = 20) { + const client = await initPodcastindexClient(); + const results = await client.recentFeeds(max); + return results; +} diff --git a/backend/src/services/programmaticPlaylists.ts b/backend/src/services/programmaticPlaylists.ts new file mode 100644 index 0000000..1d626b0 --- /dev/null +++ b/backend/src/services/programmaticPlaylists.ts @@ -0,0 +1,3246 @@ +import { prisma } from "../utils/db"; +import { lastFmService } from "./lastfm"; + +export interface ProgrammaticMix { + id: string; + type: string; + name: string; + description: string; + trackIds: string[]; + coverUrls: string[]; // For mosaic cover art + trackCount: number; + color: string; // Tailwind gradient classes for mood-reflective hero +} + +// Research-based color psychology for mix vibes +// Using actual CSS rgba values for inline styles (Tailwind classes get purged at build time) +const MIX_COLORS: Record = { + // Night/Introspection - Deep blues and purples for calm, night sky, solitude + "late-night": "linear-gradient(to bottom, rgba(30, 27, 75, 0.7), rgba(30, 58, 138, 0.5), rgba(15, 23, 42, 0.4))", + "3am-thoughts": "linear-gradient(to bottom, rgba(46, 16, 101, 0.7), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", + "night-drive": "linear-gradient(to bottom, rgba(15, 23, 42, 0.7), rgba(49, 46, 129, 0.5), rgba(88, 28, 135, 0.4))", + + // Calm/Relaxation - Teal and seafoam for spa-like tranquility + "chill": "linear-gradient(to bottom, rgba(17, 94, 89, 0.6), rgba(22, 78, 99, 0.5), rgba(15, 23, 42, 0.4))", + "coffee-shop": "linear-gradient(to bottom, rgba(120, 53, 15, 0.6), rgba(68, 64, 60, 0.5), rgba(38, 38, 38, 0.4))", + "rainy-day": "linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(31, 41, 55, 0.5), rgba(39, 39, 42, 0.4))", + "sunday-morning": "linear-gradient(to bottom, rgba(253, 186, 116, 0.4), rgba(252, 211, 77, 0.3), rgba(68, 64, 60, 0.4))", + + // Energy/Workout - Red and orange to increase heart rate + "workout": "linear-gradient(to bottom, rgba(153, 27, 27, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", + "confidence-boost": "linear-gradient(to bottom, rgba(194, 65, 12, 0.6), rgba(146, 64, 14, 0.5), rgba(68, 64, 60, 0.4))", + + // Happy/Uplifting - Yellow and warm amber for optimism + "happy": "linear-gradient(to bottom, rgba(217, 119, 6, 0.5), rgba(161, 98, 7, 0.4), rgba(68, 64, 60, 0.4))", + "summer-vibes": "linear-gradient(to bottom, rgba(8, 145, 178, 0.5), rgba(15, 118, 110, 0.4), rgba(30, 58, 138, 0.4))", + "golden-hour": "linear-gradient(to bottom, rgba(245, 158, 11, 0.5), rgba(234, 88, 12, 0.4), rgba(136, 19, 55, 0.4))", + + // Sad/Melancholy - Cool blue-grays for "feeling blue" + "melancholy": "linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(30, 58, 138, 0.5), rgba(17, 24, 39, 0.4))", + "sad-girl-sundays": "linear-gradient(to bottom, rgba(136, 19, 55, 0.5), rgba(30, 41, 59, 0.5), rgba(59, 7, 100, 0.4))", + "heartbreak-hotel": "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", + + // Party/Dance - Hot pink and magenta for club energy + "dance-floor": "linear-gradient(to bottom, rgba(162, 28, 175, 0.6), rgba(131, 24, 67, 0.5), rgba(59, 7, 100, 0.4))", + + // Acoustic/Organic - Warm browns like wood instruments + "acoustic": "linear-gradient(to bottom, rgba(146, 64, 14, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))", + "unplugged": "linear-gradient(to bottom, rgba(68, 64, 60, 0.6), rgba(120, 53, 15, 0.5), rgba(38, 38, 38, 0.4))", + + // Focus/Instrumental - Purple for creativity and concentration + "instrumental": "linear-gradient(to bottom, rgba(91, 33, 182, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))", + "focus-flow": "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(30, 41, 59, 0.5), rgba(17, 24, 39, 0.4))", + + // Adventure/Road Trip - Sunset oranges for freedom + "road-trip": "linear-gradient(to bottom, rgba(194, 65, 12, 0.6), rgba(146, 64, 14, 0.5), rgba(14, 165, 233, 0.4))", + + // Character/Mood Archetypes + "main-character": "linear-gradient(to bottom, rgba(245, 158, 11, 0.5), rgba(202, 138, 4, 0.4), rgba(124, 45, 18, 0.4))", + "villain-era": "linear-gradient(to bottom, rgba(69, 10, 10, 0.7), rgba(17, 24, 39, 0.6), rgba(0, 0, 0, 0.5))", + + // Nostalgia - Sepia and vintage tones + "throwback": "linear-gradient(to bottom, rgba(146, 64, 14, 0.5), rgba(124, 45, 18, 0.4), rgba(68, 64, 60, 0.4))", + + // Genre/Era based - More neutral but themed + "era": "linear-gradient(to bottom, rgba(68, 64, 60, 0.5), rgba(38, 38, 38, 0.4), rgba(39, 39, 42, 0.4))", + "genre": "linear-gradient(to bottom, rgba(63, 63, 70, 0.5), rgba(30, 41, 59, 0.4), rgba(17, 24, 39, 0.4))", + "top-tracks": "linear-gradient(to bottom, rgba(6, 95, 70, 0.5), rgba(17, 94, 89, 0.4), rgba(15, 23, 42, 0.4))", + "rediscover": "linear-gradient(to bottom, rgba(55, 48, 163, 0.5), rgba(76, 29, 149, 0.4), rgba(15, 23, 42, 0.4))", + "artist-similar": "linear-gradient(to bottom, rgba(107, 33, 168, 0.5), rgba(112, 26, 117, 0.4), rgba(15, 23, 42, 0.4))", + "discovery": "linear-gradient(to bottom, rgba(2, 132, 199, 0.5), rgba(30, 58, 138, 0.4), rgba(15, 23, 42, 0.4))", + + // Mood-on-demand default + "mood": "linear-gradient(to bottom, rgba(162, 28, 175, 0.5), rgba(107, 33, 168, 0.4), rgba(15, 23, 42, 0.4))", + + // Default fallback + "default": "linear-gradient(to bottom, rgba(88, 28, 135, 0.4), rgba(26, 26, 26, 1), transparent)", +}; + +// Helper to get color for a mix type +function getMixColor(type: string): string { + return MIX_COLORS[type] || MIX_COLORS["default"]; +} + +// Helper to randomly sample from array +function randomSample(array: T[], count: number): T[] { + const shuffled = [...array].sort(() => Math.random() - 0.5); + return shuffled.slice(0, count); +} + +// Helper to get seeded random number for daily consistency +function getSeededRandom(seed: string): number { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + const char = seed.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +// Type for track with album cover +type TrackWithAlbumCover = { + id: string; + album: { coverUrl: string | null; genres?: unknown }; + lastfmTags?: string[]; + essentiaGenres?: string[]; + [key: string]: unknown; +}; + +/** + * Helper to find tracks matching genre patterns. + * Uses lastfmTags and essentiaGenres on tracks (String[]) first, + * then falls back to filtering album.genres (JSON array) in memory. + */ +async function findTracksByGenrePatterns( + genrePatterns: string[], + limit: number = 100 +): Promise { + // Strategy 1: Use track's lastfmTags and essentiaGenres (native String[] fields) + const tagPatterns = genrePatterns.map(g => g.toLowerCase()); + + const tracks = await prisma.track.findMany({ + where: { + OR: [ + { lastfmTags: { hasSome: tagPatterns } }, + { essentiaGenres: { hasSome: tagPatterns } }, + ], + }, + include: { album: { select: { coverUrl: true, genres: true } } }, + take: limit, + }); + + if (tracks.length >= 15) { + return tracks; + } + + // Strategy 2: Query albums with non-empty genres and filter in memory + const albumTracks = await prisma.track.findMany({ + where: { + album: { + genres: { not: { equals: null } }, + }, + }, + include: { album: { select: { coverUrl: true, genres: true } } }, + take: limit * 3, // Get more to filter down + }); + + // Filter by genre patterns (case-insensitive partial match) + const genreMatched = albumTracks.filter(t => { + const albumGenres = t.album.genres as string[] | null; + if (!albumGenres || !Array.isArray(albumGenres)) return false; + return albumGenres.some(ag => + genrePatterns.some(gp => ag.toLowerCase().includes(gp.toLowerCase())) + ); + }); + + // Merge unique tracks + const existingIds = new Set(tracks.map(t => t.id)); + const merged = [...tracks, ...genreMatched.filter(t => !existingIds.has(t.id))]; + + return merged.slice(0, limit); +} + +export class ProgrammaticPlaylistService { + private readonly TRACK_LIMIT = 20; + private readonly DAILY_MIX_COUNT = 5; + + // Track count thresholds for mix generation + private readonly MIN_TRACKS_DAILY = 8; // Minimum to generate a daily mix + private readonly MIN_TRACKS_WEEKLY = 15; // Minimum to generate a weekly mix + private readonly DAILY_TRACK_LIMIT = 10; // Daily mix size + private readonly WEEKLY_TRACK_LIMIT = 20; // Weekly mix size + + /** + * Generate 4 daily rotating mixes + */ + async generateAllMixes( + userId: string, + forceRandom = false + ): Promise { + // Get today's date for daily rotation (or random seed if refreshing) + const today = new Date().toISOString().split("T")[0]; + const seedString = forceRandom + ? `${userId}-${Date.now()}-${Math.random()}` + : `${today}-${userId}`; + const dateSeed = getSeededRandom(seedString); + + console.log( + `[MIXES] Generating mixes for user ${userId}, forceRandom: ${forceRandom}, seed: ${dateSeed}` + ); + + // Define all possible mix types + const seedSuffix = forceRandom ? `-${Date.now()}` : ""; + const mixGenerators = [ + // Classic mixes (genre/era based) + { + fn: () => this.generateEraMix(userId, today + seedSuffix), + weight: 2, + name: "Era Mix", + }, + { + fn: () => this.generateGenreMix(userId, today + seedSuffix), + weight: 2, + name: "Genre Mix", + }, + { + fn: () => this.generateTopTracksMix(userId), + weight: 1, + name: "Top Tracks Mix", + }, + { + fn: () => + this.generateRediscoverMix(userId, today + seedSuffix), + weight: 1, + name: "Rediscover Mix", + }, + { + fn: () => this.generateArtistSimilarMix(userId), + weight: 1, + name: "Artist Similar Mix", + }, + { + fn: () => + this.generateRandomDiscoveryMix(userId, today + seedSuffix), + weight: 1, + name: "Random Discovery Mix", + }, + { + fn: () => this.generatePartyMix(userId, today + seedSuffix), + weight: 2, + name: "Party Mix", + }, + { + fn: () => this.generateChillMix(userId, today + seedSuffix), + weight: 2, + name: "Chill Mix", + }, + { + fn: () => this.generateWorkoutMix(userId, today + seedSuffix), + weight: 2, + name: "Workout Mix", + }, + { + fn: () => this.generateFocusMix(userId, today + seedSuffix), + weight: 2, + name: "Focus Mix", + }, + // Audio analysis-based mixes (using Essentia features) + { + fn: () => this.generateHighEnergyMix(userId, today + seedSuffix), + weight: 2, + name: "High Energy Mix", + }, + { + fn: () => this.generateLateNightMix(userId, today + seedSuffix), + weight: 2, + name: "Late Night Mix", + }, + { + fn: () => this.generateHappyMix(userId, today + seedSuffix), + weight: 2, + name: "Happy Vibes Mix", + }, + { + fn: () => this.generateMelancholyMix(userId, today + seedSuffix), + weight: 2, + name: "Melancholy Mix", + }, + { + fn: () => this.generateDanceFloorMix(userId, today + seedSuffix), + weight: 2, + name: "Dance Floor Mix", + }, + { + fn: () => this.generateAcousticMix(userId, today + seedSuffix), + weight: 2, + name: "Acoustic Mix", + }, + { + fn: () => this.generateInstrumentalMix(userId, today + seedSuffix), + weight: 2, + name: "Instrumental Mix", + }, + { + fn: () => this.generateRoadTripMix(userId, today + seedSuffix), + weight: 2, + name: "Road Trip Mix", + }, + // Day-of-week mixes + { + fn: () => this.generateDayMix(userId), + weight: 1, + name: "Day Mix", + }, + // Curated Vibe Mixes (Daily, 10 tracks) + { + fn: () => this.generateSadGirlSundays(userId, today + seedSuffix), + weight: 2, + name: "Sad Girl Sundays", + }, + { + fn: () => this.generateMainCharacterEnergy(userId, today + seedSuffix), + weight: 2, + name: "Main Character Energy", + }, + { + fn: () => this.generateVillainEra(userId, today + seedSuffix), + weight: 2, + name: "Villain Era", + }, + { + fn: () => this.generate3AMThoughts(userId, today + seedSuffix), + weight: 2, + name: "3AM Thoughts", + }, + { + fn: () => this.generateHotGirlWalk(userId, today + seedSuffix), + weight: 2, + name: "Hot Girl Walk", + }, + { + fn: () => this.generateRageCleaning(userId, today + seedSuffix), + weight: 2, + name: "Rage Cleaning", + }, + { + fn: () => this.generateGoldenHour(userId, today + seedSuffix), + weight: 2, + name: "Golden Hour", + }, + { + fn: () => this.generateShowerKaraoke(userId, today + seedSuffix), + weight: 2, + name: "Shower Karaoke", + }, + { + fn: () => this.generateInMyFeelings(userId, today + seedSuffix), + weight: 2, + name: "In My Feelings", + }, + { + fn: () => this.generateMidnightDrive(userId, today + seedSuffix), + weight: 2, + name: "Midnight Drive", + }, + { + fn: () => this.generateCoffeeShopVibes(userId, today + seedSuffix), + weight: 2, + name: "Coffee Shop Vibes", + }, + { + fn: () => this.generateRomanticizeYourLife(userId, today + seedSuffix), + weight: 2, + name: "Romanticize Your Life", + }, + { + fn: () => this.generateThatGirlEra(userId, today + seedSuffix), + weight: 2, + name: "That Girl Era", + }, + { + fn: () => this.generateUnhinged(userId, today + seedSuffix), + weight: 2, + name: "Unhinged", + }, + // Weekly Curated Mixes (20 tracks) + { + fn: () => this.generateDeepCuts(userId, today + seedSuffix), + weight: 1, + name: "Deep Cuts", + }, + { + fn: () => this.generateKeyJourney(userId, today + seedSuffix), + weight: 1, + name: "Key Journey", + }, + { + fn: () => this.generateTempoFlow(userId, today + seedSuffix), + weight: 1, + name: "Tempo Flow", + }, + { + fn: () => this.generateVocalDetox(userId, today + seedSuffix), + weight: 1, + name: "Vocal Detox", + }, + { + fn: () => this.generateMinorKeyMix(userId, today + seedSuffix), + weight: 1, + name: "Minor Key Mondays", + }, + ]; + + // Select 5 mixes based on date seed + const selectedIndices: number[] = []; + let seed = dateSeed; + + console.log( + `[MIXES] Selecting ${this.DAILY_MIX_COUNT} mixes from ${mixGenerators.length} types...` + ); + + while (selectedIndices.length < this.DAILY_MIX_COUNT) { + seed = (seed * 9301 + 49297) % 233280; + const index = seed % mixGenerators.length; + if (!selectedIndices.includes(index)) { + selectedIndices.push(index); + console.log( + `[MIXES] Selected index ${index}: ${mixGenerators[index].name}` + ); + } + } + + console.log( + `[MIXES] Final selected indices: [${selectedIndices.join(", ")}]` + ); + + // Generate selected mixes + const mixPromises = selectedIndices.map((i) => { + console.log(`[MIXES] Generating ${mixGenerators[i].name}...`); + return mixGenerators[i].fn(); + }); + const mixes = await Promise.all(mixPromises); + + console.log(`[MIXES] Generated ${mixes.length} mixes before filtering`); + mixes.forEach((mix, i) => { + if (mix === null) { + console.log( + `[MIXES] Mix ${i} (${ + mixGenerators[selectedIndices[i]].name + }) returned NULL` + ); + } else { + console.log( + `[MIXES] Mix ${i}: ${mix.name} (${mix.trackCount} tracks)` + ); + } + }); + + // Filter out null mixes + let finalMixes = mixes.filter( + (mix): mix is ProgrammaticMix => mix !== null + ); + console.log( + `[MIXES] Returning ${finalMixes.length} mixes after filtering nulls` + ); + + // If we don't have 5 mixes, try to fill gaps with successful generators + if (finalMixes.length < this.DAILY_MIX_COUNT) { + console.log( + `[MIXES] Only got ${finalMixes.length} mixes, trying to fill gaps...` + ); + + // Try generating from all types that weren't selected or failed + const successfulTypes = new Set(finalMixes.map((m) => m.type)); + const attemptedIndices = new Set(selectedIndices); + + for ( + let i = 0; + i < mixGenerators.length && + finalMixes.length < this.DAILY_MIX_COUNT; + i++ + ) { + if (!attemptedIndices.has(i)) { + console.log( + `[MIXES] Attempting fallback: ${mixGenerators[i].name}` + ); + const fallbackMix = await mixGenerators[i].fn(); + if (fallbackMix && !successfulTypes.has(fallbackMix.type)) { + finalMixes.push(fallbackMix); + successfulTypes.add(fallbackMix.type); + console.log( + `[MIXES] Fallback succeeded: ${fallbackMix.name}` + ); + } + } + } + + console.log(`[MIXES] After fallbacks: ${finalMixes.length} mixes`); + } + + // Check if user has saved mood preferences and generate their personalized mood mix + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { moodMixParams: true } + }); + + if (user?.moodMixParams && typeof user.moodMixParams === 'object') { + const params = user.moodMixParams as any; + const presetName = params.presetName || "Mood"; + const mixName = `Your ${presetName} Mix`; + + console.log(`[MIXES] User has saved mood preferences, generating "${mixName}"...`); + const moodMix = await this.generateMoodOnDemand(userId, params); + if (moodMix) { + // Override the mix metadata with the preset name + const yourMoodMix: ProgrammaticMix = { + ...moodMix, + id: "your-mood-mix", + type: "mood", + name: mixName, + description: `Based on your ${presetName.toLowerCase()} preferences`, + }; + finalMixes.push(yourMoodMix); + console.log(`[MIXES] Added "${mixName}" with ${moodMix.trackCount} tracks`); + } + } + } catch (err) { + console.error("[MIXES] Error generating user's mood mix:", err); + } + + return finalMixes; + } + + /** + * Generate ONE era-based mix (rotating decade daily) + */ + async generateEraMix( + userId: string, + today: string + ): Promise { + // Get all decades + const albums = await prisma.album.findMany({ + where: { tracks: { some: {} } }, + select: { year: true }, + }); + + const decades = new Set(); + albums.forEach((album) => { + if (album.year) { + const decade = Math.floor(album.year / 10) * 10; + decades.add(decade); + } + }); + + if (decades.size === 0) return null; + + // Pick one decade based on today's date + const decadeArray = Array.from(decades).sort((a, b) => b - a); + const decadeSeed = getSeededRandom(`era-${today}`); + const selectedDecade = decadeArray[decadeSeed % decadeArray.length]; + + // Get ALL tracks from this decade + const tracks = await prisma.track.findMany({ + where: { + album: { + year: { gte: selectedDecade, lt: selectedDecade + 10 }, + }, + }, + include: { + album: { select: { coverUrl: true } }, + }, + }); + + if (tracks.length < 15) return null; + + // Random sample 20 tracks + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `era-${selectedDecade}-${today}`, + type: "era", + name: `Your ${selectedDecade}s Mix`, + description: `Random picks from the ${selectedDecade}s`, + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("era"), + }; + } + + /** + * Generate ONE genre-based mix (rotating genre daily) + */ + async generateGenreMix( + userId: string, + today: string + ): Promise { + // Get top genres + const genres = await prisma.genre.findMany({ + include: { + _count: { select: { trackGenres: true } }, + }, + orderBy: { + trackGenres: { _count: "desc" }, + }, + take: 20, + }); + + console.log(`[GENRE MIX] Found ${genres.length} genres total`); + const validGenres = genres.filter((g) => g._count.trackGenres >= 5); + console.log( + `[GENRE MIX] ${validGenres.length} genres have >= 5 tracks` + ); + if (validGenres.length === 0) { + console.log(`[GENRE MIX] FAILED: No genres with enough tracks`); + return null; + } + + // Pick one genre based on today's date + const genreSeed = getSeededRandom(`genre-${today}`); + const selectedGenre = validGenres[genreSeed % validGenres.length]; + + // Get ALL tracks from this genre + const trackGenres = await prisma.trackGenre.findMany({ + where: { genreId: selectedGenre.id }, + include: { + track: { + include: { + album: { select: { coverUrl: true } }, + }, + }, + }, + }); + + const tracks = trackGenres.map((tg) => tg.track); + if (tracks.length < 5) return null; + + // Random sample 20 tracks + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `genre-${selectedGenre.id}-${today}`, + type: "genre", + name: `Your ${selectedGenre.name} Mix`, + description: `Random ${selectedGenre.name} picks`, + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("genre"), + }; + } + + /** + * Generate "Your Top 20" mix + */ + async generateTopTracksMix( + userId: string + ): Promise { + const playStats = await prisma.play.groupBy({ + by: ["trackId"], + where: { userId }, + _count: { trackId: true }, + orderBy: { _count: { trackId: "desc" } }, + take: this.TRACK_LIMIT, + }); + + console.log( + `[TOP TRACKS MIX] Found ${playStats.length} unique played tracks` + ); + if (playStats.length < 5) { + console.log( + `[TOP TRACKS MIX] FAILED: Only ${playStats.length} tracks (need at least 5)` + ); + return null; + } + + const trackIds = playStats.map((p) => p.trackId); + const tracks = await prisma.track.findMany({ + where: { id: { in: trackIds } }, + include: { + album: { select: { coverUrl: true } }, + }, + }); + + // Preserve play count order + const orderedTracks = trackIds + .map((id) => tracks.find((t) => t.id === id)) + .filter((t) => t !== undefined); + + const coverUrls = orderedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: "top-tracks", + type: "top-tracks", + name: "Your Top 20", + description: "Your most played tracks", + trackIds: orderedTracks.map((t) => t.id), + coverUrls, + trackCount: orderedTracks.length, + color: getMixColor("top-tracks"), + }; + } + + /** + * Generate "Rediscover" mix with daily rotation + */ + async generateRediscoverMix( + userId: string, + today: string + ): Promise { + // Get tracks with low play count (0-2 plays) + const allTracks = await prisma.track.findMany({ + include: { + _count: { + select: { + plays: { where: { userId } }, + }, + }, + album: { select: { coverUrl: true } }, + }, + }); + + const underplayedTracks = allTracks.filter((t) => t._count.plays <= 2); + + if (underplayedTracks.length < 5) return null; + + // Use date seed for consistent daily selection + const seed = getSeededRandom(`rediscover-${today}`); + let random = seed; + const shuffled = underplayedTracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `rediscover-${today}`, + type: "rediscover", + name: "Rediscover", + description: "Hidden gems you rarely play", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("rediscover"), + }; + } + + /** + * Generate "More Like X" mix + */ + async generateArtistSimilarMix( + userId: string + ): Promise { + // Get most played artist from last 7 days + const recentPlays = await prisma.play.findMany({ + where: { + userId, + playedAt: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + include: { + track: { + include: { + album: { select: { artistId: true } }, + }, + }, + }, + }); + + console.log( + `[ARTIST SIMILAR MIX] Found ${recentPlays.length} plays in last 7 days` + ); + if (recentPlays.length === 0) { + console.log(`[ARTIST SIMILAR MIX] FAILED: No plays in last 7 days`); + return null; + } + + // Count plays by artist + const artistPlayCounts = new Map(); + recentPlays.forEach((play) => { + const artistId = play.track.album.artistId; + artistPlayCounts.set( + artistId, + (artistPlayCounts.get(artistId) || 0) + 1 + ); + }); + + // Get top artist + const topArtistId = Array.from(artistPlayCounts.entries()).sort( + (a, b) => b[1] - a[1] + )[0][0]; + + const topArtist = await prisma.artist.findUnique({ + where: { id: topArtistId }, + }); + + if (!topArtist || !topArtist.name) { + console.log( + `[ARTIST SIMILAR MIX] FAILED: Top artist not found or has no name` + ); + return null; + } + + console.log(`[ARTIST SIMILAR MIX] Top artist: ${topArtist.name}`); + + // Get similar artists from Last.fm + try { + const similarArtists = await lastFmService.getSimilarArtists( + topArtist.name, + "10" + ); + + console.log( + `[ARTIST SIMILAR MIX] Last.fm returned ${similarArtists.length} similar artists` + ); + + const similarArtistNames = similarArtists.map((a) => a.name); + const artistsInLibrary = await prisma.artist.findMany({ + where: { name: { in: similarArtistNames } }, + include: { + albums: { + include: { + tracks: { + include: { + album: { select: { coverUrl: true } }, + }, + }, + }, + }, + }, + }); + + console.log( + `[ARTIST SIMILAR MIX] Found ${artistsInLibrary.length} similar artists in library` + ); + + const tracks = artistsInLibrary.flatMap((artist) => + artist.albums.flatMap((album) => album.tracks) + ); + + console.log( + `[ARTIST SIMILAR MIX] Total tracks from similar artists: ${tracks.length}` + ); + + if (tracks.length < 5) { + console.log( + `[ARTIST SIMILAR MIX] FAILED: Only ${tracks.length} tracks (need at least 5)` + ); + return null; + } + + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `artist-similar-${topArtistId}`, + type: "artist-similar", + name: `More Like ${topArtist.name}`, + description: `Similar artists you might enjoy`, + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("artist-similar"), + }; + } catch (error) { + console.error("Failed to generate artist similar mix:", error); + return null; + } + } + + /** + * Generate random discovery mix with daily rotation + */ + async generateRandomDiscoveryMix( + userId: string, + today: string + ): Promise { + const totalAlbums = await prisma.album.count({ + where: { tracks: { some: {} } }, + }); + + if (totalAlbums < 10) return null; + + // Use date as seed for consistent daily randomness + const seed = getSeededRandom(`random-${today}`) % totalAlbums; + + const randomAlbums = await prisma.album.findMany({ + where: { tracks: { some: {} } }, + include: { + tracks: { + include: { + album: { select: { coverUrl: true } }, + }, + }, + }, + skip: seed, + take: 5, // Just a few albums + }); + + const tracks = randomAlbums.flatMap((album) => album.tracks); + if (tracks.length < 5) return null; + + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = randomAlbums + .filter((a) => a.coverUrl) + .slice(0, 4) + .map((a) => a.coverUrl!); + + return { + id: `random-discovery-${today}`, + type: "discovery", + name: "Random Discovery", + description: "Random albums to explore today", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("discovery"), + }; + } + + /** + * Generate "Party Playlist" mix - upbeat dance, electronic, pop tracks + * Uses multiple strategies: Genre table, album.genre, audio analysis + */ + async generatePartyMix( + userId: string, + today: string + ): Promise { + const partyGenres = [ + "dance", "electronic", "pop", "disco", "house", "techno", "edm", + "funk", "electro", "dance pop", "club", "eurodance", "trance", + "dubstep", "drum and bass", "hip hop" + ]; + + let tracks: any[] = []; + + // Strategy 1: Genre table + const genres = await prisma.genre.findMany({ + where: { name: { in: partyGenres, mode: "insensitive" } }, + include: { + trackGenres: { + include: { + track: { include: { album: { select: { coverUrl: true } } } }, + }, + take: 50, + }, + }, + }); + tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); + console.log(`[PARTY MIX] Found ${tracks.length} tracks from Genre table`); + + // Strategy 2: Album genre field (using helper for proper JSON array handling) + if (tracks.length < 15) { + const albumGenreTracks = await findTracksByGenrePatterns(partyGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[PARTY MIX] After album genre fallback: ${tracks.length} tracks`); + } + + // Strategy 3: Audio analysis (high energy, high danceability) + if (tracks.length < 15) { + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { danceability: { gte: 0.7 } }, + { AND: [{ energy: { gte: 0.7 } }, { bpm: { gte: 110 } }] }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[PARTY MIX] After audio analysis fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[PARTY MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + // Use date seed for consistent daily selection + const seed = getSeededRandom(`party-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `party-${today}`, + type: "dance-floor", + name: "Party Playlist", + description: "High energy dance, EDM, and pop hits", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("dance-floor"), + }; + } + + /** + * Generate "Chill Mix" - relaxing, mellow tracks + * Enhanced mode: Uses ML moodRelaxed prediction + * Standard mode: Uses energy/arousal heuristics + */ + async generateChillMix( + userId: string, + today: string + ): Promise { + // Strategy 1: Enhanced mode - ML moodRelaxed prediction + let tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + analysisMode: "enhanced", + AND: [ + { moodRelaxed: { gte: 0.5 } }, + { moodAggressive: { lte: 0.3 } }, + { energy: { lte: 0.55 } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + + console.log(`[CHILL MIX] Enhanced mode: Found ${tracks.length} tracks`); + + // Strategy 2: Standard mode fallback + if (tracks.length < this.MIN_TRACKS_DAILY) { + console.log(`[CHILL MIX] Falling back to Standard mode`); + tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + // MUST be low-to-moderate energy + { energy: { lte: 0.55 } }, + // MUST be slow-to-moderate tempo + { bpm: { lte: 115 } }, + // Plus additional mellow indicator + { + OR: [ + { arousal: { lte: 0.55 } }, + { acousticness: { gte: 0.3 } }, + { valence: { lte: 0.65 } }, + ], + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + console.log(`[CHILL MIX] Standard mode: Found ${tracks.length} tracks`); + } + + console.log(`[CHILL MIX] Total: ${tracks.length} tracks matching criteria`); + + if (tracks.length < this.MIN_TRACKS_DAILY) { + console.log(`[CHILL MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})`); + return null; + } + + const seed = getSeededRandom(`chill-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + // Determine if daily or weekly based on available tracks + const isWeekly = tracks.length >= this.MIN_TRACKS_WEEKLY; + const trackLimit = isWeekly ? this.WEEKLY_TRACK_LIMIT : this.DAILY_TRACK_LIMIT; + const selectedTracks = shuffled.slice(0, trackLimit); + + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `chill-${today}`, + type: "chill", + name: "Chill Mix", + description: "Relax and unwind with mellow vibes", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("chill"), + }; + } + + /** + * Generate "Workout Mix" - high energy, motivational tracks + * Enhanced mode: Uses ML high arousal + moodAggressive + * Standard mode: Uses energy/BPM heuristics + genres + */ + async generateWorkoutMix( + userId: string, + today: string + ): Promise { + const workoutGenres = [ + "rock", "metal", "hard rock", "alternative rock", "punk", + "hip hop", "rap", "trap", "hardcore", "metalcore", + "industrial", "drum and bass", "hardstyle", "nu metal", + "electronic", "edm", "house", "techno", "pop punk" + ]; + + let tracks: any[] = []; + + // Strategy 1: Enhanced mode - high arousal and energy + const enhancedTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + analysisMode: "enhanced", + AND: [ + { arousal: { gte: 0.6 } }, + { energy: { gte: 0.6 } }, + { bpm: { gte: 110 } }, + // Not too relaxed + { moodRelaxed: { lte: 0.4 } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = enhancedTracks; + console.log(`[WORKOUT MIX] Enhanced mode: Found ${tracks.length} tracks`); + + // Strategy 2: Standard mode fallback - audio analysis + if (tracks.length < 15) { + console.log(`[WORKOUT MIX] Falling back to Standard mode`); + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { AND: [{ energy: { gte: 0.65 } }, { bpm: { gte: 115 } }] }, + { moodTags: { hasSome: ["workout", "energetic", "upbeat", "powerful"] } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[WORKOUT MIX] Standard mode: Total ${tracks.length} tracks`); + } + + // Strategy 2: Genre table + if (tracks.length < 15) { + const genres = await prisma.genre.findMany({ + where: { name: { in: workoutGenres, mode: "insensitive" } }, + include: { + trackGenres: { + include: { + track: { include: { album: { select: { coverUrl: true } } } }, + }, + take: 50, + }, + }, + }); + const genreTracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...genreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[WORKOUT MIX] After Genre table: ${tracks.length} tracks`); + } + + // Strategy 3: Album genre field (using helper for proper JSON array handling) + if (tracks.length < 15) { + const albumGenreTracks = await findTracksByGenrePatterns(workoutGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[WORKOUT MIX] After album genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[WORKOUT MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`workout-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `workout-${today}`, + type: "workout", + name: "Workout Mix", + description: "High energy tracks to power your workout", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("workout"), + }; + } + + /** + * Generate "Focus Mix" - instrumental, minimal vocals, concentration music + * Uses multiple strategies: Genre table, album.genre, audio analysis + */ + async generateFocusMix( + userId: string, + today: string + ): Promise { + const focusGenres = [ + "classical", "instrumental", "jazz", "piano", "ambient", + "post-rock", "math rock", "soundtrack", "score", + "contemporary classical", "minimal", "modern classical", "neoclassical" + ]; + + let tracks: any[] = []; + + // Strategy 1: Genre table + const genres = await prisma.genre.findMany({ + where: { name: { in: focusGenres, mode: "insensitive" } }, + include: { + trackGenres: { + include: { + track: { include: { album: { select: { coverUrl: true } } } }, + }, + take: 50, + }, + }, + }); + tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); + console.log(`[FOCUS MIX] Found ${tracks.length} tracks from Genre table`); + + // Strategy 2: Album genre field (using helper for proper JSON array handling) + if (tracks.length < 15) { + const albumGenreTracks = await findTracksByGenrePatterns(focusGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[FOCUS MIX] After album genre fallback: ${tracks.length} tracks`); + } + + // Strategy 3: Audio analysis (high instrumentalness, moderate energy) + if (tracks.length < 15) { + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + instrumentalness: { gte: 0.5 }, + energy: { gte: 0.2, lte: 0.7 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[FOCUS MIX] After audio analysis fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[FOCUS MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`focus-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `focus-${today}`, + type: "focus-flow", + name: "Focus Mix", + description: "Concentration music for deep work", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("focus-flow"), + }; + } + + // ============================================================ + // AUDIO ANALYSIS-BASED MIXES (Using Essentia features) + // ============================================================ + + /** + * Generate "High Energy" mix using audio analysis + * Criteria: energy >= 0.7, BPM >= 120 + * Fallback: energetic genres if no audio analysis + */ + async generateHighEnergyMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Audio analysis + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + energy: { gte: 0.7 }, + bpm: { gte: 120 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = audioTracks; + console.log(`[HIGH ENERGY MIX] Found ${tracks.length} tracks from audio analysis`); + + // Strategy 2: Fallback to energetic genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const energyGenres = ["rock", "metal", "punk", "electronic", "edm", "dance", "hip hop", "trap"]; + const albumGenreTracks = await findTracksByGenrePatterns(energyGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[HIGH ENERGY MIX] After genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[HIGH ENERGY MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`high-energy-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `high-energy-${today}`, + type: "workout", + name: "High Energy", + description: "Fast-paced tracks to get you moving", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("workout"), + }; + } + + /** + * Generate "Late Night" mix using audio analysis + * Enhanced mode: Uses ML moodRelaxed and low moodAggressive + * Standard mode: Uses energy, BPM, arousal heuristics + */ + async generateLateNightMix( + userId: string, + today: string + ): Promise { + // First try Enhanced mode (ML mood predictions) + let tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + analysisMode: "enhanced", + AND: [ + // High relaxed mood (ML) + { moodRelaxed: { gte: 0.5 } }, + // Low aggression (ML) + { moodAggressive: { lte: 0.4 } }, + // Low-moderate energy + { energy: { lte: 0.5 } }, + // Slow-moderate tempo + { bpm: { lte: 110 } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + + console.log(`[LATE NIGHT MIX] Enhanced mode: Found ${tracks.length} tracks`); + + // Fallback to Standard mode if not enough Enhanced tracks + if (tracks.length < this.MIN_TRACKS_DAILY) { + console.log(`[LATE NIGHT MIX] Falling back to Standard mode`); + tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + // MUST have low energy + { energy: { lte: 0.45 } }, + // MUST have moderate-slow tempo + { bpm: { lte: 110 } }, + // Plus at least one additional mellow indicator + { + OR: [ + { arousal: { lte: 0.5 } }, + { valence: { lte: 0.6 } }, + { acousticness: { gte: 0.3 } }, + ], + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + console.log(`[LATE NIGHT MIX] Standard mode: Found ${tracks.length} tracks`); + } + + console.log(`[LATE NIGHT MIX] Total: ${tracks.length} tracks matching criteria`); + + // No fallback padding - if not enough truly mellow tracks, don't generate + if (tracks.length < this.MIN_TRACKS_DAILY) { + console.log(`[LATE NIGHT MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})`); + return null; + } + + const seed = getSeededRandom(`late-night-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + // Determine if daily or weekly based on available tracks + const isWeekly = tracks.length >= this.MIN_TRACKS_WEEKLY; + const trackLimit = isWeekly ? this.WEEKLY_TRACK_LIMIT : this.DAILY_TRACK_LIMIT; + const selectedTracks = shuffled.slice(0, trackLimit); + + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `late-night-${today}`, + type: "late-night", + name: "Late Night", + description: "Mellow vibes for the quiet hours", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("late-night"), + }; + } + + /** + * Generate "Happy Vibes" mix using audio analysis + * Enhanced mode: Uses ML moodHappy prediction + * Standard mode: Uses valence/energy heuristics + */ + async generateHappyMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Enhanced mode - ML moodHappy prediction + const enhancedTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + analysisMode: "enhanced", + moodHappy: { gte: 0.6 }, + moodSad: { lte: 0.3 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = enhancedTracks; + console.log(`[HAPPY MIX] Enhanced mode: Found ${tracks.length} tracks`); + + // Strategy 2: Standard mode fallback - valence/energy heuristics + if (tracks.length < 15) { + const standardTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + valence: { gte: 0.6 }, + energy: { gte: 0.5 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...standardTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[HAPPY MIX] After Standard fallback: ${tracks.length} tracks`); + } + + // Strategy 2: Fallback to upbeat/happy genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const happyGenres = ["pop", "funk", "disco", "soul", "reggae", "ska", "motown"]; + const albumGenreTracks = await findTracksByGenrePatterns(happyGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[HAPPY MIX] After genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[HAPPY MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`happy-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `happy-${today}`, + type: "happy", + name: "Happy Vibes", + description: "Feel-good tracks to brighten your day", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("happy"), + }; + } + + /** + * Generate "Melancholy" mix using audio analysis + * Enhanced mode: Uses ML moodSad prediction + * Standard mode: Uses valence heuristics + minor key + */ + async generateMelancholyMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Enhanced mode - ML moodSad prediction + const enhancedTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + analysisMode: "enhanced", + moodSad: { gte: 0.5 }, + moodHappy: { lte: 0.4 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 150, + }); + console.log(`[MELANCHOLY MIX] Enhanced mode: Found ${enhancedTracks.length} tracks`); + + if (enhancedTracks.length >= 15) { + tracks = enhancedTracks; + } else { + // Strategy 2: Standard mode fallback + console.log(`[MELANCHOLY MIX] Falling back to Standard mode`); + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + valence: { lte: 0.35 }, + energy: { lte: 0.6 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 150, + }); + console.log(`[MELANCHOLY MIX] Standard mode: Found ${audioTracks.length} low-valence tracks`); + + // Further filter: prefer minor key OR sad mood tags + tracks = audioTracks.filter((t) => { + const hasMinorKey = t.keyScale === "minor"; + const hasSadTags = t.moodTags?.some((tag: string) => + ["sad", "melancholic", "melancholy", "moody", "atmospheric"].includes(tag.toLowerCase()) + ); + const hasLastfmSadTags = t.lastfmTags?.some((tag: string) => + ["sad", "melancholic", "melancholy", "depressing", "emotional", "heartbreak"].includes(tag.toLowerCase()) + ); + return hasMinorKey || hasSadTags || hasLastfmSadTags; + }); + console.log(`[MELANCHOLY MIX] After tag filter: ${tracks.length} tracks`); + } + + // Strategy 2: Fallback to sad/emotional genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const sadGenres = ["blues", "soul", "ballad", "singer-songwriter", "slowcore", "sadcore"]; + const albumGenreTracks = await findTracksByGenrePatterns(sadGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[MELANCHOLY MIX] After genre fallback: ${tracks.length} tracks`); + } + + // Require minimum 15 tracks for a meaningful playlist + if (tracks.length < 15) { + console.log(`[MELANCHOLY MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + // Score and sort by "melancholy-ness" (only for tracks with audio analysis) + const sortedTracks = tracks.sort((a, b) => { + // Lower valence = more melancholy (score should be lower = better) + const aScore = + (a.valence || 0.5) * 2 + // Valence is primary factor + (a.energy || 0.5) + // Lower energy is better + (a.keyScale === "minor" ? 0 : 0.3); // Minor key bonus + const bScore = + (b.valence || 0.5) * 2 + + (b.energy || 0.5) + + (b.keyScale === "minor" ? 0 : 0.3); + return aScore - bScore; + }); + + const seed = getSeededRandom(`melancholy-${today}`); + let random = seed; + // Take top 50 most melancholy tracks, then shuffle + const shuffled = sortedTracks.slice(0, 50).sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `melancholy-${today}`, + type: "melancholy", + name: "Melancholy", + description: "Introspective tracks for reflective moments", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("melancholy"), + }; + } + + /** + * Generate "Dance Floor" mix using audio analysis + * Criteria: danceability >= 0.7, BPM 110-140 + * Fallback: dance/electronic genres if no audio analysis + */ + async generateDanceFloorMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Audio analysis + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + danceability: { gte: 0.7 }, + bpm: { gte: 110, lte: 140 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = audioTracks; + console.log(`[DANCE FLOOR MIX] Found ${tracks.length} tracks from audio analysis`); + + // Strategy 2: Fallback to dance genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const danceGenres = ["dance", "electronic", "edm", "house", "disco", "techno", "pop"]; + const albumGenreTracks = await findTracksByGenrePatterns(danceGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[DANCE FLOOR MIX] After genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[DANCE FLOOR MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`dance-floor-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `dance-floor-${today}`, + type: "dance-floor", + name: "Dance Floor", + description: "High danceability tracks with perfect tempo", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("dance-floor"), + }; + } + + /** + * Generate "Acoustic Afternoon" mix using audio analysis + * Criteria: acousticness >= 0.6, energy 0.3-0.6 + * Fallback: acoustic/folk/singer-songwriter genres + */ + async generateAcousticMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Audio analysis + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + acousticness: { gte: 0.6 }, + energy: { gte: 0.3, lte: 0.6 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = audioTracks; + console.log(`[ACOUSTIC MIX] Found ${tracks.length} tracks from audio analysis`); + + // Strategy 2: Fallback to acoustic genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const acousticGenres = ["acoustic", "folk", "singer-songwriter", "unplugged", "indie folk"]; + const albumGenreTracks = await findTracksByGenrePatterns(acousticGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[ACOUSTIC MIX] After genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[ACOUSTIC MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`acoustic-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `acoustic-${today}`, + type: "acoustic", + name: "Acoustic Afternoon", + description: "Stripped-down, organic sounds", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("acoustic"), + }; + } + + /** + * Generate "Instrumental Focus" mix using audio analysis + * Criteria: instrumentalness >= 0.7, energy 0.3-0.6 + * Fallback: instrumental/classical/soundtrack genres + */ + async generateInstrumentalMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Audio analysis + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + instrumentalness: { gte: 0.7 }, + energy: { gte: 0.3, lte: 0.6 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = audioTracks; + console.log(`[INSTRUMENTAL MIX] Found ${tracks.length} tracks from audio analysis`); + + // Strategy 2: Fallback to instrumental genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const instrumentalGenres = ["instrumental", "classical", "soundtrack", "score", "ambient", "post-rock"]; + const albumGenreTracks = await findTracksByGenrePatterns(instrumentalGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[INSTRUMENTAL MIX] After genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[INSTRUMENTAL MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`instrumental-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `instrumental-${today}`, + type: "instrumental", + name: "Instrumental Focus", + description: "No vocals, pure concentration", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("instrumental"), + }; + } + + // ============================================================ + // LAST.FM TAG-BASED MIXES + // ============================================================ + + /** + * Generate mix based on Last.fm mood tags + */ + async generateMoodTagMix( + userId: string, + today: string, + moodTag: string, + mixName: string, + mixDescription: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + lastfmTags: { + has: moodTag, + }, + }, + include: { + album: { select: { coverUrl: true } }, + }, + take: 100, + }); + + if (tracks.length < 15) return null; + + const seed = getSeededRandom(`mood-${moodTag}-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `mood-${moodTag}-${today}`, + type: `mood-${moodTag}`, + name: mixName, + description: mixDescription, + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("mood"), + }; + } + + /** + * Generate "Road Trip" mix - using tags + */ + async generateRoadTripMix( + userId: string, + today: string + ): Promise { + let tracks: any[] = []; + + // Strategy 1: Last.fm/mood tags + const taggedTracks = await prisma.track.findMany({ + where: { + OR: [ + { lastfmTags: { hasSome: ["driving", "road trip", "travel", "summer"] } }, + { moodTags: { hasSome: ["energetic", "upbeat", "happy"] } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + tracks = taggedTracks; + console.log(`[ROAD TRIP MIX] Found ${tracks.length} tracks from tags`); + + // Strategy 2: Audio analysis (medium-high energy, good tempo) + if (tracks.length < 15) { + const audioTracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + energy: { gte: 0.5, lte: 0.8 }, + bpm: { gte: 100, lte: 130 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...audioTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[ROAD TRIP MIX] After audio fallback: ${tracks.length} tracks`); + } + + // Strategy 3: Fallback to upbeat rock/pop genres (using helper for proper JSON array handling) + if (tracks.length < 15) { + const roadTripGenres = ["rock", "pop", "indie", "alternative", "classic rock"]; + const albumGenreTracks = await findTracksByGenrePatterns(roadTripGenres, 100); + const existingIds = new Set(tracks.map(t => t.id)); + tracks = [...tracks, ...albumGenreTracks.filter(t => !existingIds.has(t.id))]; + console.log(`[ROAD TRIP MIX] After genre fallback: ${tracks.length} tracks`); + } + + if (tracks.length < 15) { + console.log(`[ROAD TRIP MIX] FAILED: Only ${tracks.length} tracks found`); + return null; + } + + const seed = getSeededRandom(`road-trip-${today}`); + let random = seed; + const shuffled = tracks.sort(() => { + random = (random * 9301 + 49297) % 233280; + return random / 233280 - 0.5; + }); + + const selectedTracks = shuffled.slice(0, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `road-trip-${today}`, + type: "road-trip", + name: "Road Trip", + description: "Perfect soundtrack for the open road", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("road-trip"), + }; + } + + // ============================================================ + // DAY-OF-WEEK MIXES + // ============================================================ + + /** + * Generate day-specific mix based on the current day + */ + async generateDayMix(userId: string): Promise { + const dayOfWeek = new Date().getDay(); + const today = new Date().toISOString().split("T")[0]; + + // Different vibes for different days + switch (dayOfWeek) { + case 0: // Sunday - Relaxed + return this.generateSundayMix(userId, today); + case 1: // Monday - Motivation + return this.generateMondayMix(userId, today); + case 5: // Friday - Party + return this.generateFridayMix(userId, today); + default: + return null; + } + } + + async generateSundayMix( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + OR: [ + { + analysisStatus: "completed", + energy: { lte: 0.5 }, + acousticness: { gte: 0.5 }, + }, + { + lastfmTags: { hasSome: ["relaxed", "calm", "peaceful", "chill", "sunday"] }, + }, + ], + }, + include: { + album: { select: { coverUrl: true } }, + }, + take: 100, + }); + + if (tracks.length < 15) return null; + + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `sunday-${today}`, + type: "sunday-morning", + name: "Sunday Morning", + description: "Peaceful tunes for a lazy Sunday", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("sunday-morning"), + }; + } + + async generateMondayMix( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + OR: [ + { + analysisStatus: "completed", + energy: { gte: 0.6 }, + valence: { gte: 0.5 }, + }, + { + lastfmTags: { hasSome: ["motivation", "uplifting", "energetic", "happy"] }, + }, + ], + }, + include: { + album: { select: { coverUrl: true } }, + }, + take: 100, + }); + + if (tracks.length < 15) return null; + + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `monday-${today}`, + type: "confidence-boost", + name: "Monday Motivation", + description: "Start your week with energy", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("confidence-boost"), + }; + } + + async generateFridayMix( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + OR: [ + { + analysisStatus: "completed", + danceability: { gte: 0.7 }, + energy: { gte: 0.6 }, + }, + { + lastfmTags: { hasSome: ["party", "dance", "fun", "groovy"] }, + }, + ], + }, + include: { + album: { select: { coverUrl: true } }, + }, + take: 100, + }); + + if (tracks.length < 15) return null; + + const selectedTracks = randomSample(tracks, this.TRACK_LIMIT); + const coverUrls = selectedTracks + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `friday-${today}`, + type: "dance-floor", + name: "Friday Night", + description: "Weekend vibes to kick off the party", + trackIds: selectedTracks.map((t) => t.id), + coverUrls, + trackCount: selectedTracks.length, + color: getMixColor("dance-floor"), + }; + } + + // ============================================================ + // CURATED VIBE MIXES (Daily, 10 tracks) + // These are "mood" mixes based on audio analysis and vibes + // ============================================================ + + /** + * "Sad Girl Sundays" - Melancholic introspection + * valence < 0.3 + keyScale = 'minor' + arousal < 0.4 + * Only available on Sundays + */ + async generateSadGirlSundays( + userId: string, + today: string + ): Promise { + // Only generate on Sundays (day 0) + const dayOfWeek = new Date().getDay(); + if (dayOfWeek !== 0) return null; + + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { valence: { lte: 0.35 } }, + { keyScale: "minor" }, + ], + }, + { + AND: [ + { valence: { lte: 0.3 } }, + { arousal: { lte: 0.4 } }, + ], + }, + { + lastfmTags: { hasSome: ["sad", "melancholic", "heartbreak", "emotional"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `sad-girl-sundays-${today}`, + type: "sad-girl-sundays", + name: "Sad Girl Sundays", + description: "Melancholic introspection and feelings", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("sad-girl-sundays"), + }; + } + + /** + * "Main Character Energy" - Walking through life like a movie + * valence > 0.6 + energy > 0.6 + danceability > 0.5 + */ + async generateMainCharacterEnergy( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { valence: { gte: 0.55 } }, + { energy: { gte: 0.55 } }, + { danceability: { gte: 0.5 } }, + ], + }, + { + lastfmTags: { hasSome: ["empowering", "confident", "uplifting", "anthemic"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `main-character-${today}`, + type: "main-character", + name: "Main Character Energy", + description: "You're the protagonist today", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("main-character"), + }; + } + + /** + * "Villain Era" - Dark, empowering, dramatic + * keyScale = 'minor' + energy > 0.7 + moodTags includes 'aggressive' + */ + async generateVillainEra( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { keyScale: "minor" }, + { energy: { gte: 0.65 } }, + ], + }, + { + moodTags: { hasSome: ["aggressive", "dark", "intense"] }, + }, + { + lastfmTags: { hasSome: ["dark", "aggressive", "intense", "powerful"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `villain-era-${today}`, + type: "villain-era", + name: "Villain Era", + description: "Embrace your dark side", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("villain-era"), + }; + } + + /** + * "3AM Thoughts" - Late night overthinking + * arousal < 0.3 + energy < 0.4 + valence < 0.4 + */ + async generate3AMThoughts( + userId: string, + today: string + ): Promise { + // STRICT criteria: truly late-night introspective tracks only + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + { arousal: { lte: 0.4 } }, + { energy: { lte: 0.5 } }, + { bpm: { lte: 110 } }, + { + OR: [ + { valence: { lte: 0.5 } }, + { acousticness: { gte: 0.3 } }, + ], + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < this.MIN_TRACKS_DAILY) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `3am-thoughts-${today}`, + type: "3am-thoughts", + name: "3AM Thoughts", + description: "Late night overthinking companion", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("3am-thoughts"), + }; + } + + /** + * "Hot Girl Walk" - Confident, upbeat cardio + * danceability > 0.7 + bpm 100-130 + energy > 0.6 + */ + async generateHotGirlWalk( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { danceability: { gte: 0.65 } }, + { bpm: { gte: 95, lte: 135 } }, + { energy: { gte: 0.55 } }, + ], + }, + { + AND: [ + { valence: { gte: 0.6 } }, + { energy: { gte: 0.6 } }, + ], + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `hot-girl-walk-${today}`, + type: "hot-girl-walk", + name: "Hot Girl Walk", + description: "Confidence boost for your walk", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("confidence-boost"), + }; + } + + /** + * "Rage Cleaning" - Aggressive productivity + * energy > 0.8 + arousal > 0.7 + bpm > 130 + */ + async generateRageCleaning( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { energy: { gte: 0.75 } }, + { arousal: { gte: 0.65 } }, + { bpm: { gte: 125 } }, + ], + }, + { + AND: [ + { energy: { gte: 0.8 } }, + { danceability: { gte: 0.6 } }, + ], + }, + { + moodTags: { hasSome: ["aggressive", "energetic"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `rage-cleaning-${today}`, + type: "rage-cleaning", + name: "Rage Cleaning", + description: "Aggressive productivity fuel", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("workout"), + }; + } + + /** + * "Golden Hour" - Warm, hopeful, sunset vibes + * valence > 0.5 + acousticness > 0.4 + energy 0.3-0.6 + */ + async generateGoldenHour( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { valence: { gte: 0.45 } }, + { acousticness: { gte: 0.35 } }, + { energy: { gte: 0.25, lte: 0.65 } }, + ], + }, + { + lastfmTags: { hasSome: ["warm", "sunset", "dreamy", "peaceful"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `golden-hour-${today}`, + type: "golden-hour", + name: "Golden Hour", + description: "Warm sunset vibes", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("golden-hour"), + }; + } + + /** + * "Shower Karaoke" - Belters you can't help but sing + * instrumentalness < 0.3 + energy > 0.6 + valence > 0.5 + */ + async generateShowerKaraoke( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + { instrumentalness: { lte: 0.35 } }, + { energy: { gte: 0.55 } }, + { valence: { gte: 0.45 } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `shower-karaoke-${today}`, + type: "shower-karaoke", + name: "Shower Karaoke", + description: "Belters you can't help but sing", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("happy"), + }; + } + + /** + * "In My Feelings" - Deep emotional processing + * valence < 0.35 + arousal < 0.5 + acousticness > 0.3 + */ + async generateInMyFeelings( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { valence: { lte: 0.4 } }, + { arousal: { lte: 0.55 } }, + { acousticness: { gte: 0.25 } }, + ], + }, + { + lastfmTags: { hasSome: ["emotional", "heartbreak", "feelings", "vulnerable"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `in-my-feelings-${today}`, + type: "in-my-feelings", + name: "In My Feelings", + description: "Let it all out", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("heartbreak-hotel"), + }; + } + + /** + * "Midnight Drive" - Cruising at night, contemplative + * energy 0.4-0.6 + arousal 0.3-0.5 + bpm 90-120 + */ + async generateMidnightDrive( + userId: string, + today: string + ): Promise { + // STRICT criteria: contemplative driving music only + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + // MUST be moderate energy (not too mellow, not too intense) + { energy: { gte: 0.3, lte: 0.65 } }, + // MUST have cruising tempo + { bpm: { gte: 80, lte: 130 } }, + // Plus mellow mood indicator + { + OR: [ + { arousal: { lte: 0.6 } }, + { valence: { gte: 0.3, lte: 0.7 } }, + ], + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < this.MIN_TRACKS_DAILY) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `midnight-drive-${today}`, + type: "midnight-drive", + name: "Midnight Drive", + description: "Perfect for late night cruising", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("night-drive"), + }; + } + + /** + * "Coffee Shop Vibes" - Cozy background energy + * acousticness > 0.5 + energy 0.2-0.5 + instrumentalness > 0.3 + */ + async generateCoffeeShopVibes( + userId: string, + today: string + ): Promise { + // STRICT criteria: cozy, background-appropriate music only + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + // MUST be low-to-moderate energy + { energy: { lte: 0.55 } }, + // MUST be moderate-slow tempo + { bpm: { lte: 120 } }, + // Plus at least one cozy indicator + { + OR: [ + { acousticness: { gte: 0.35 } }, + { instrumentalness: { gte: 0.25 } }, + ], + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < this.MIN_TRACKS_DAILY) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `coffee-shop-${today}`, + type: "coffee-shop", + name: "Coffee Shop Vibes", + description: "Cozy background music", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("coffee-shop"), + }; + } + + /** + * "Romanticize Your Life" - Dreamy, aesthetic moments + * valence 0.4-0.7 + arousal 0.3-0.6 + acousticness > 0.3 + */ + async generateRomanticizeYourLife( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { + AND: [ + { valence: { gte: 0.35, lte: 0.75 } }, + { arousal: { gte: 0.25, lte: 0.65 } }, + { acousticness: { gte: 0.25 } }, + ], + }, + { + lastfmTags: { hasSome: ["dreamy", "aesthetic", "cinematic", "romantic"] }, + }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `romanticize-${today}`, + type: "romanticize", + name: "Romanticize Your Life", + description: "Make every moment aesthetic", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("golden-hour"), + }; + } + + /** + * "That Girl Era" - Self-improvement anthem energy + * valence > 0.6 + energy > 0.5 + danceability > 0.5 + */ + async generateThatGirlEra( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + AND: [ + { valence: { gte: 0.55 } }, + { energy: { gte: 0.45 } }, + { danceability: { gte: 0.45 } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 50, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `that-girl-era-${today}`, + type: "that-girl-era", + name: "That Girl Era", + description: "Self-improvement mode activated", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("confidence-boost"), + }; + } + + /** + * "Unhinged" - Chaotic, weird, fun + * High variance in features, unexpected combinations + */ + async generateUnhinged( + userId: string, + today: string + ): Promise { + // Get a variety of tracks with extreme features + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + OR: [ + { energy: { gte: 0.85 } }, + { energy: { lte: 0.15 } }, + { valence: { gte: 0.9 } }, + { valence: { lte: 0.1 } }, + { bpm: { gte: 160 } }, + { bpm: { lte: 70 } }, + { danceability: { gte: 0.9 } }, + ], + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + + if (tracks.length < 8) return null; + + const shuffled = randomSample(tracks, this.DAILY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `unhinged-${today}`, + type: "unhinged", + name: "Unhinged", + description: "Embrace the chaos", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("dance-floor"), + }; + } + + // ============================================================ + // WEEKLY CURATED MIXES (20 tracks) + // ============================================================ + + /** + * "Deep Cuts" - Hidden gems from your library + * Tracks with playCount < 3 from artists you play often + */ + async generateDeepCuts( + userId: string, + today: string + ): Promise { + // Get tracks that haven't been played much + const tracks = await prisma.track.findMany({ + where: { + plays: { + none: {}, + }, + }, + include: { + album: { + select: { + coverUrl: true, + artist: { select: { id: true } }, + } + } + }, + take: 200, + }); + + if (tracks.length < 15) { + // Fallback: tracks with few plays + const lowPlayTracks = await prisma.track.findMany({ + include: { + album: { select: { coverUrl: true } }, + _count: { select: { plays: true } }, + }, + take: 200, + }); + + const filtered = lowPlayTracks + .filter(t => t._count.plays <= 3) + .map(t => ({ ...t, album: t.album })); + + if (filtered.length < 15) return null; + + const shuffled = randomSample(filtered, this.WEEKLY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `deep-cuts-${today}`, + type: "deep-cuts", + name: "Deep Cuts", + description: "Hidden gems waiting to be discovered", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("rediscover"), + }; + } + + const shuffled = randomSample(tracks, this.WEEKLY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `deep-cuts-${today}`, + type: "deep-cuts", + name: "Deep Cuts", + description: "Hidden gems waiting to be discovered", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("rediscover"), + }; + } + + /** + * "Key Journey" - Harmonic progression + * Tracks ordered by circle of fifths key progression + */ + async generateKeyJourney( + userId: string, + today: string + ): Promise { + // Circle of fifths order + const keyOrder = ["C", "G", "D", "A", "E", "B", "F#", "Db", "Ab", "Eb", "Bb", "F"]; + + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + key: { not: null }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 200, + }); + + if (tracks.length < 15) return null; + + // Group by key + const byKey = new Map(); + for (const track of tracks) { + const key = track.key || "C"; + if (!byKey.has(key)) byKey.set(key, []); + byKey.get(key)!.push(track); + } + + // Build a journey through keys + const journey: typeof tracks = []; + const seed = getSeededRandom(`key-journey-${today}`); + let seedVal = seed; + + for (const key of keyOrder) { + const keyTracks = byKey.get(key) || []; + if (keyTracks.length > 0 && journey.length < this.WEEKLY_TRACK_LIMIT) { + // Pick 1-2 tracks from each key + const count = Math.min(2, keyTracks.length, this.WEEKLY_TRACK_LIMIT - journey.length); + seedVal = (seedVal * 9301 + 49297) % 233280; + const shuffled = keyTracks.sort(() => { + seedVal = (seedVal * 9301 + 49297) % 233280; + return seedVal / 233280 - 0.5; + }); + journey.push(...shuffled.slice(0, count)); + } + } + + if (journey.length < 15) return null; + + const coverUrls = journey + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `key-journey-${today}`, + type: "key-journey", + name: "Key Journey", + description: "Harmonic progression through your library", + trackIds: journey.map((t) => t.id), + coverUrls, + trackCount: journey.length, + color: getMixColor("instrumental"), + }; + } + + /** + * "Tempo Flow" - Energy arc throughout + * Start low BPM, build to peak, come down + */ + async generateTempoFlow( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + bpm: { not: null }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 200, + }); + + if (tracks.length < 15) return null; + + // Sort by BPM + const sorted = [...tracks].sort((a, b) => (a.bpm || 0) - (b.bpm || 0)); + + // Build an arc: slow → fast → slow + const slow = sorted.filter(t => (t.bpm || 0) < 100); + const medium = sorted.filter(t => (t.bpm || 0) >= 100 && (t.bpm || 0) < 130); + const fast = sorted.filter(t => (t.bpm || 0) >= 130); + + const flow: typeof tracks = []; + + // Intro: 4 slow tracks + flow.push(...randomSample(slow, Math.min(4, slow.length))); + // Build: 4 medium tracks + flow.push(...randomSample(medium, Math.min(5, medium.length))); + // Peak: 5 fast tracks + flow.push(...randomSample(fast, Math.min(6, fast.length))); + // Cool down: 3 medium tracks + flow.push(...randomSample(medium.filter(t => !flow.includes(t)), Math.min(3, medium.length))); + // Outro: 3 slow tracks + flow.push(...randomSample(slow.filter(t => !flow.includes(t)), Math.min(2, slow.length))); + + if (flow.length < 15) return null; + + const coverUrls = flow + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `tempo-flow-${today}`, + type: "tempo-flow", + name: "Tempo Flow", + description: "An energy journey through BPM", + trackIds: flow.slice(0, this.WEEKLY_TRACK_LIMIT).map((t) => t.id), + coverUrls, + trackCount: Math.min(flow.length, this.WEEKLY_TRACK_LIMIT), + color: getMixColor("workout"), + }; + } + + /** + * "Vocal Detox" - Pure instrumental escape + * instrumentalness > 0.8 + variety of moods + */ + async generateVocalDetox( + userId: string, + today: string + ): Promise { + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + instrumentalness: { gte: 0.75 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + + if (tracks.length < 15) return null; + + const shuffled = randomSample(tracks, this.WEEKLY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `vocal-detox-${today}`, + type: "vocal-detox", + name: "Vocal Detox", + description: "Pure instrumental escape", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("instrumental"), + }; + } + + /** + * "Minor Key Mondays" - All minor key bangers + * keyScale = 'minor' + energy > 0.5 + * Only available on Mondays + */ + async generateMinorKeyMix( + userId: string, + today: string + ): Promise { + // Only generate on Mondays (day 1) + const dayOfWeek = new Date().getDay(); + if (dayOfWeek !== 1) return null; + + const tracks = await prisma.track.findMany({ + where: { + analysisStatus: "completed", + keyScale: "minor", + energy: { gte: 0.45 }, + }, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + + if (tracks.length < 15) return null; + + const shuffled = randomSample(tracks, this.WEEKLY_TRACK_LIMIT); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + return { + id: `minor-key-${today}`, + type: "melancholy", + name: "Minor Key Mondays", + description: "All minor key bangers", + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("melancholy"), + }; + } + + // ============================================================ + // MOOD ON DEMAND - Generate a mix based on specific criteria + // ============================================================ + + /** + * Generate a custom mood mix based on audio feature parameters + * Supports both basic audio features and ML mood predictions + */ + async generateMoodOnDemand( + userId: string, + params: { + // Basic audio features + valence?: { min?: number; max?: number }; + energy?: { min?: number; max?: number }; + danceability?: { min?: number; max?: number }; + acousticness?: { min?: number; max?: number }; + instrumentalness?: { min?: number; max?: number }; + arousal?: { min?: number; max?: number }; + bpm?: { min?: number; max?: number }; + keyScale?: "major" | "minor"; + // ML mood predictions (require Enhanced mode analysis) + moodHappy?: { min?: number; max?: number }; + moodSad?: { min?: number; max?: number }; + moodRelaxed?: { min?: number; max?: number }; + moodAggressive?: { min?: number; max?: number }; + moodParty?: { min?: number; max?: number }; + moodAcoustic?: { min?: number; max?: number }; + moodElectronic?: { min?: number; max?: number }; + limit?: number; + } + ): Promise { + const where: any = { + analysisStatus: "completed", + }; + + // Check if any ML mood params are being used + const mlMoodParams = ['moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic']; + const usesMLMoods = mlMoodParams.some(key => params[key as keyof typeof params] !== undefined); + + // If using ML moods, require enhanced analysis mode + if (usesMLMoods) { + where.analysisMode = "enhanced"; + } + + // Basic audio feature filters + if (params.valence) { + where.valence = {}; + if (params.valence.min !== undefined) where.valence.gte = params.valence.min; + if (params.valence.max !== undefined) where.valence.lte = params.valence.max; + } + if (params.energy) { + where.energy = {}; + if (params.energy.min !== undefined) where.energy.gte = params.energy.min; + if (params.energy.max !== undefined) where.energy.lte = params.energy.max; + } + if (params.danceability) { + where.danceability = {}; + if (params.danceability.min !== undefined) where.danceability.gte = params.danceability.min; + if (params.danceability.max !== undefined) where.danceability.lte = params.danceability.max; + } + if (params.acousticness) { + where.acousticness = {}; + if (params.acousticness.min !== undefined) where.acousticness.gte = params.acousticness.min; + if (params.acousticness.max !== undefined) where.acousticness.lte = params.acousticness.max; + } + if (params.instrumentalness) { + where.instrumentalness = {}; + if (params.instrumentalness.min !== undefined) where.instrumentalness.gte = params.instrumentalness.min; + if (params.instrumentalness.max !== undefined) where.instrumentalness.lte = params.instrumentalness.max; + } + if (params.arousal) { + where.arousal = {}; + if (params.arousal.min !== undefined) where.arousal.gte = params.arousal.min; + if (params.arousal.max !== undefined) where.arousal.lte = params.arousal.max; + } + if (params.bpm) { + where.bpm = {}; + if (params.bpm.min !== undefined) where.bpm.gte = params.bpm.min; + if (params.bpm.max !== undefined) where.bpm.lte = params.bpm.max; + } + if (params.keyScale) { + where.keyScale = params.keyScale; + } + + // ML mood prediction filters + if (params.moodHappy) { + where.moodHappy = {}; + if (params.moodHappy.min !== undefined) where.moodHappy.gte = params.moodHappy.min; + if (params.moodHappy.max !== undefined) where.moodHappy.lte = params.moodHappy.max; + } + if (params.moodSad) { + where.moodSad = {}; + if (params.moodSad.min !== undefined) where.moodSad.gte = params.moodSad.min; + if (params.moodSad.max !== undefined) where.moodSad.lte = params.moodSad.max; + } + if (params.moodRelaxed) { + where.moodRelaxed = {}; + if (params.moodRelaxed.min !== undefined) where.moodRelaxed.gte = params.moodRelaxed.min; + if (params.moodRelaxed.max !== undefined) where.moodRelaxed.lte = params.moodRelaxed.max; + } + if (params.moodAggressive) { + where.moodAggressive = {}; + if (params.moodAggressive.min !== undefined) where.moodAggressive.gte = params.moodAggressive.min; + if (params.moodAggressive.max !== undefined) where.moodAggressive.lte = params.moodAggressive.max; + } + if (params.moodParty) { + where.moodParty = {}; + if (params.moodParty.min !== undefined) where.moodParty.gte = params.moodParty.min; + if (params.moodParty.max !== undefined) where.moodParty.lte = params.moodParty.max; + } + if (params.moodAcoustic) { + where.moodAcoustic = {}; + if (params.moodAcoustic.min !== undefined) where.moodAcoustic.gte = params.moodAcoustic.min; + if (params.moodAcoustic.max !== undefined) where.moodAcoustic.lte = params.moodAcoustic.max; + } + if (params.moodElectronic) { + where.moodElectronic = {}; + if (params.moodElectronic.min !== undefined) where.moodElectronic.gte = params.moodElectronic.min; + if (params.moodElectronic.max !== undefined) where.moodElectronic.lte = params.moodElectronic.max; + } + + const tracks = await prisma.track.findMany({ + where, + include: { album: { select: { coverUrl: true } } }, + take: 100, + }); + + const limit = params.limit || 15; + if (tracks.length < Math.min(limit, 8)) return null; + + const shuffled = randomSample(tracks, limit); + const coverUrls = shuffled + .filter((t) => t.album.coverUrl) + .slice(0, 4) + .map((t) => t.album.coverUrl!); + + const timestamp = Date.now(); + return { + id: `mood-on-demand-${timestamp}`, + type: "mood-on-demand", + name: "Custom Mood Mix", + description: `Generated just for you`, + trackIds: shuffled.map((t) => t.id), + coverUrls, + trackCount: shuffled.length, + color: getMixColor("mood"), + }; + } +} + +export const programmaticPlaylistService = new ProgrammaticPlaylistService(); diff --git a/backend/src/services/rateLimiter.ts b/backend/src/services/rateLimiter.ts new file mode 100644 index 0000000..4a7bcad --- /dev/null +++ b/backend/src/services/rateLimiter.ts @@ -0,0 +1,303 @@ +/** + * Global Rate Limiter Service + * + * Provides centralized rate limiting with exponential backoff for all external API calls. + * Implements circuit breaker pattern to pause requests when rate limited. + */ + +import PQueue from "p-queue"; + +interface RateLimitConfig { + /** Requests per interval */ + intervalCap: number; + /** Interval in milliseconds */ + interval: number; + /** Maximum concurrent requests */ + concurrency: number; + /** Maximum retries on 429 */ + maxRetries: number; + /** Base delay for exponential backoff (ms) */ + baseDelay: number; +} + +interface ServiceConfig { + lastfm: RateLimitConfig; + musicbrainz: RateLimitConfig; + deezer: RateLimitConfig; + lidarr: RateLimitConfig; + coverart: RateLimitConfig; +} + +// Service-specific rate limit configurations +const SERVICE_CONFIGS: ServiceConfig = { + lastfm: { + intervalCap: 3, // 3 requests per second (Last.fm allows 5, but we're conservative) + interval: 1000, + concurrency: 2, + maxRetries: 3, + baseDelay: 1000, + }, + musicbrainz: { + intervalCap: 1, // 1 request per second (MusicBrainz is strict) + interval: 1100, // Slightly over 1 second to be safe + concurrency: 1, + maxRetries: 3, + baseDelay: 2000, + }, + deezer: { + intervalCap: 25, // Deezer is more lenient + interval: 5000, + concurrency: 5, + maxRetries: 2, + baseDelay: 500, + }, + lidarr: { + intervalCap: 10, // Local service, can be faster + interval: 1000, + concurrency: 3, + maxRetries: 2, + baseDelay: 500, + }, + coverart: { + intervalCap: 5, // Cover Art Archive - conservative rate + interval: 1000, + concurrency: 3, + maxRetries: 2, + baseDelay: 1000, + }, +}; + +type ServiceName = keyof ServiceConfig; + +interface CircuitState { + isOpen: boolean; + openedAt: number; + consecutiveFailures: number; + resetAfterMs: number; +} + +class GlobalRateLimiter { + private queues: Map = new Map(); + private circuitBreakers: Map = new Map(); + private globalPaused = false; + private globalPauseUntil = 0; + + constructor() { + // Initialize queues for each service + for (const [service, config] of Object.entries(SERVICE_CONFIGS)) { + this.queues.set( + service as ServiceName, + new PQueue({ + concurrency: config.concurrency, + intervalCap: config.intervalCap, + interval: config.interval, + carryoverConcurrencyCount: true, + }) + ); + + this.circuitBreakers.set(service as ServiceName, { + isOpen: false, + openedAt: 0, + consecutiveFailures: 0, + resetAfterMs: 30000, // 30 seconds default + }); + } + + console.log("Global rate limiter initialized"); + } + + /** + * Execute a request with rate limiting and automatic retry + */ + async execute( + service: ServiceName, + requestFn: () => Promise, + options?: { + priority?: number; + skipRetry?: boolean; + } + ): Promise { + const queue = this.queues.get(service); + const config = SERVICE_CONFIGS[service]; + + if (!queue || !config) { + throw new Error(`Unknown service: ${service}`); + } + + // Check global pause + if (this.globalPaused && Date.now() < this.globalPauseUntil) { + const waitTime = this.globalPauseUntil - Date.now(); + console.log(`Global rate limit pause - waiting ${waitTime}ms`); + await this.sleep(waitTime); + } + + // Check circuit breaker + const circuit = this.circuitBreakers.get(service)!; + if (circuit.isOpen) { + const elapsed = Date.now() - circuit.openedAt; + if (elapsed < circuit.resetAfterMs) { + // Circuit is open, wait or throw + const waitTime = circuit.resetAfterMs - elapsed; + console.log( + `Circuit breaker open for ${service} - waiting ${waitTime}ms` + ); + await this.sleep(waitTime); + } + // Reset circuit to initial state + circuit.isOpen = false; + circuit.consecutiveFailures = 0; + circuit.resetAfterMs = 30000; // Reset to initial 30 seconds + } + + // Execute with retry logic + let lastError: Error | null = null; + const maxRetries = options?.skipRetry ? 0 : config.maxRetries; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const result = await queue.add( + async () => { + return await requestFn(); + }, + { priority: options?.priority ?? 0 } + ); + + // Success - reset failure count + circuit.consecutiveFailures = 0; + return result as T; + } catch (error: any) { + lastError = error; + + // Check if it's a rate limit error + const isRateLimit = + error.response?.status === 429 || + error.message?.includes("429") || + error.message?.toLowerCase().includes("rate limit"); + + if (isRateLimit) { + circuit.consecutiveFailures++; + + // Calculate backoff delay + const delay = this.calculateBackoff( + attempt, + config.baseDelay, + error + ); + console.warn( + `Rate limited by ${service} (attempt ${attempt + 1}/${ + maxRetries + 1 + }) - backing off ${delay}ms` + ); + + // If too many failures, open circuit + if (circuit.consecutiveFailures >= 5) { + circuit.isOpen = true; + circuit.openedAt = Date.now(); + circuit.resetAfterMs = Math.min( + 60000, + circuit.resetAfterMs * 2 + ); + console.warn( + `Circuit breaker opened for ${service} - will reset in ${circuit.resetAfterMs}ms` + ); + } + + if (attempt < maxRetries) { + await this.sleep(delay); + continue; + } + } + + // Non-rate-limit error or max retries reached + throw error; + } + } + + throw lastError || new Error("Request failed after retries"); + } + + /** + * Calculate exponential backoff delay + */ + private calculateBackoff( + attempt: number, + baseDelay: number, + error?: any + ): number { + // Check for Retry-After header + const retryAfter = error?.response?.headers?.["retry-after"]; + if (retryAfter) { + const parsed = parseInt(retryAfter, 10); + if (!isNaN(parsed)) { + return parsed * 1000; // Convert to ms + } + } + + // Exponential backoff with jitter + const exponentialDelay = baseDelay * Math.pow(2, attempt); + const jitter = Math.random() * 1000; + return Math.min(exponentialDelay + jitter, 60000); // Cap at 60 seconds + } + + /** + * Pause all requests globally (for severe rate limiting) + */ + pauseAll(durationMs: number) { + this.globalPaused = true; + this.globalPauseUntil = Date.now() + durationMs; + console.warn(`Global rate limiter paused for ${durationMs}ms`); + } + + /** + * Resume all requests + */ + resume() { + this.globalPaused = false; + this.globalPauseUntil = 0; + console.log("Global rate limiter resumed"); + } + + /** + * Get queue statistics + */ + getStats(): Record { + const stats: any = {}; + for (const [service, queue] of this.queues.entries()) { + stats[service] = { + pending: queue.pending, + size: queue.size, + }; + } + return stats; + } + + /** + * Wait for all pending requests to complete + */ + async drain(): Promise { + const promises = Array.from(this.queues.values()).map((queue) => + queue.onIdle() + ); + await Promise.all(promises); + } + + /** + * Clear all pending requests + */ + clear() { + for (const queue of this.queues.values()) { + queue.clear(); + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +// Singleton instance +export const rateLimiter = new GlobalRateLimiter(); + +// Export types for use in other services +export type { ServiceName, RateLimitConfig }; + diff --git a/backend/src/services/rss-parser.ts b/backend/src/services/rss-parser.ts new file mode 100644 index 0000000..75bf9c3 --- /dev/null +++ b/backend/src/services/rss-parser.ts @@ -0,0 +1,281 @@ +import Parser from "rss-parser"; + +interface RSSPodcast { + title: string; + author?: string; + description?: string; + imageUrl?: string; + language?: string; + explicit?: boolean; + itunesId?: string; +} + +interface RSSEpisode { + guid: string; + title: string; + description?: string; + audioUrl: string; + duration: number; // seconds + publishedAt: Date; + episodeNumber?: number; + season?: number; + imageUrl?: string; + fileSize?: number; // bytes + mimeType?: string; +} + +interface ParsedPodcastFeed { + podcast: RSSPodcast; + episodes: RSSEpisode[]; +} + +class RSSParserService { + private parser: Parser; + + constructor() { + this.parser = new Parser({ + customFields: { + feed: [ + ["itunes:author", "itunesAuthor"], + ["itunes:image", "itunesImage"], + ["itunes:explicit", "itunesExplicit"], + ["itunes:type", "itunesType"], + ], + item: [ + ["itunes:author", "itunesAuthor"], + ["itunes:duration", "itunesDuration"], + ["itunes:image", "itunesImage"], + ["itunes:episode", "itunesEpisode"], + ["itunes:season", "itunesSeason"], + ["itunes:explicit", "itunesExplicit"], + ], + }, + }); + } + + /** + * Parse an RSS podcast feed from a URL + */ + async parseFeed(feedUrl: string): Promise { + try { + console.log(`\n [RSS PARSER] Fetching feed: ${feedUrl}`); + const feed = await this.parser.parseURL(feedUrl); + + // Extract podcast metadata + const podcast: RSSPodcast = { + title: feed.title || "Unknown Podcast", + author: (feed as any).itunesAuthor || feed.author || undefined, + description: feed.description || undefined, + imageUrl: this.extractImageUrl(feed), + language: feed.language || undefined, + explicit: this.parseExplicit((feed as any).itunesExplicit), + itunesId: this.extractItunesId(feed), + }; + + console.log(` Podcast: ${podcast.title}`); + console.log(` Author: ${podcast.author || "Unknown"}`); + console.log(` Episodes found: ${feed.items?.length || 0}`); + + // Extract episodes + const episodes: RSSEpisode[] = (feed.items || []) + .map((item) => { + try { + // Find audio enclosure + const audioEnclosure = this.findAudioEnclosure(item); + if (!audioEnclosure) { + console.warn( + ` Skipping episode "${item.title}" - no audio found` + ); + return null; + } + + const episode: RSSEpisode = { + guid: item.guid || item.link || item.title || "", + title: item.title || "Unknown Episode", + description: + item.content || + item.contentSnippet || + undefined, + audioUrl: audioEnclosure.url, + duration: this.parseDuration( + (item as any).itunesDuration + ), + publishedAt: item.pubDate + ? new Date(item.pubDate) + : new Date(), + episodeNumber: (item as any).itunesEpisode + ? parseInt((item as any).itunesEpisode) + : undefined, + season: (item as any).itunesSeason + ? parseInt((item as any).itunesSeason) + : undefined, + imageUrl: + this.extractImageUrl(item) || + podcast.imageUrl || + undefined, + fileSize: audioEnclosure.length + ? parseInt(audioEnclosure.length) + : undefined, + mimeType: audioEnclosure.type || "audio/mpeg", + }; + + return episode; + } catch (error: any) { + console.error( + ` Error parsing episode "${item.title}":`, + error.message + ); + return null; + } + }) + .filter((ep): ep is RSSEpisode => ep !== null); + + console.log(` Successfully parsed ${episodes.length} episodes`); + + return { podcast, episodes }; + } catch (error: any) { + console.error( + `\n [RSS PARSER] Failed to parse feed:`, + error.message + ); + throw new Error(`Failed to parse podcast feed: ${error.message}`); + } + } + + /** + * Extract image URL from feed/item + */ + private extractImageUrl(data: any): string | undefined { + // Try iTunes image first + if (data.itunesImage) { + if (typeof data.itunesImage === "string") { + return data.itunesImage; + } + if (data.itunesImage.href) { + return data.itunesImage.href; + } + if (data.itunesImage.$ && data.itunesImage.$.href) { + return data.itunesImage.$.href; + } + } + + // Try standard image field + if (data.image) { + if (typeof data.image === "string") { + return data.image; + } + if (data.image.url) { + return data.image.url; + } + } + + return undefined; + } + + /** + * Find audio enclosure in episode + */ + private findAudioEnclosure( + item: any + ): { url: string; type?: string; length?: string } | null { + // Check enclosure field + if (item.enclosure) { + const enc = item.enclosure; + if (enc.url && this.isAudioMimeType(enc.type)) { + return { + url: enc.url, + type: enc.type, + length: enc.length, + }; + } + } + + // Check enclosures array + if (Array.isArray(item.enclosures)) { + for (const enc of item.enclosures) { + if (enc.url && this.isAudioMimeType(enc.type)) { + return { + url: enc.url, + type: enc.type, + length: enc.length, + }; + } + } + } + + return null; + } + + /** + * Check if MIME type is audio + */ + private isAudioMimeType(mimeType?: string): boolean { + if (!mimeType) return false; + return ( + mimeType.startsWith("audio/") || + mimeType.includes("mpeg") || + mimeType.includes("mp3") || + mimeType.includes("m4a") + ); + } + + /** + * Parse iTunes duration format + * Supports: "HH:MM:SS", "MM:SS", or just seconds + */ + private parseDuration(duration?: string): number { + if (!duration) return 0; + + // If it's already a number (seconds) + const asNumber = parseInt(duration); + if (!isNaN(asNumber) && asNumber.toString() === duration) { + return asNumber; + } + + // Parse time format (HH:MM:SS or MM:SS) + const parts = duration.split(":").map((p) => parseInt(p)); + if (parts.length === 3) { + // HH:MM:SS + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + // MM:SS + return parts[0] * 60 + parts[1]; + } + + return 0; + } + + /** + * Parse explicit flag + */ + private parseExplicit(explicit?: string): boolean { + if (!explicit) return false; + const lower = explicit.toLowerCase(); + return lower === "yes" || lower === "true" || lower === "explicit"; + } + + /** + * Extract iTunes ID from feed + */ + private extractItunesId(feed: any): string | undefined { + // Try to extract from feed link (e.g., https://podcasts.apple.com/us/podcast/podcast-name/id123456789) + if (feed.link) { + const match = feed.link.match(/\/id(\d+)/); + if (match) { + return match[1]; + } + } + + // Try from feed image URL + if (feed.image?.url) { + const match = feed.image.url.match(/\/id(\d+)/); + if (match) { + return match[1]; + } + } + + return undefined; + } +} + +export const rssParserService = new RSSParserService(); diff --git a/backend/src/services/search.ts b/backend/src/services/search.ts new file mode 100644 index 0000000..7fb96c0 --- /dev/null +++ b/backend/src/services/search.ts @@ -0,0 +1,384 @@ +import { prisma } from "../utils/db"; +import { redisClient } from "../utils/redis"; + +interface SearchOptions { + query: string; + limit?: number; + offset?: number; +} + +interface ArtistSearchResult { + id: string; + name: string; + mbid: string; + heroUrl: string | null; + rank: number; +} + +interface AlbumSearchResult { + id: string; + title: string; + artistId: string; + artistName: string; + year: number | null; + coverUrl: string | null; + rank: number; +} + +interface TrackSearchResult { + id: string; + title: string; + albumId: string; + albumTitle: string; + artistId: string; + artistName: string; + duration: number; + rank: number; +} + +interface PodcastSearchResult { + id: string; + title: string; + author: string | null; + description: string | null; + imageUrl: string | null; + episodeCount: number; +} + +export class SearchService { + /** + * Convert user query to PostgreSQL tsquery format + * Splits on whitespace and adds prefix matching (:*) + * Example: "radio head" -> "radio:* & head:*" + */ + private queryToTsquery(query: string): string { + return query + .trim() + .split(/\s+/) + .map((term) => `${term.replace(/[^\w]/g, "")}:*`) + .join(" & "); + } + + async searchArtists({ + query, + limit = 20, + offset = 0, + }: SearchOptions): Promise { + if (!query || query.trim().length === 0) { + return []; + } + + const tsquery = this.queryToTsquery(query); + + try { + const results = await prisma.$queryRaw` + SELECT + id, + name, + mbid, + "heroUrl", + ts_rank(search_vector, to_tsquery('english', ${tsquery})) AS rank + FROM "Artist" + WHERE search_vector @@ to_tsquery('english', ${tsquery}) + ORDER BY rank DESC, name ASC + LIMIT ${limit} + OFFSET ${offset} + `; + + return results; + } catch (error) { + console.error("Artist search error:", error); + // Fallback to LIKE query if full-text search fails + const results = await prisma.artist.findMany({ + where: { + name: { + contains: query, + mode: "insensitive", + }, + }, + select: { + id: true, + name: true, + mbid: true, + heroUrl: true, + }, + take: limit, + skip: offset, + orderBy: { + name: "asc", + }, + }); + + return results.map((r) => ({ ...r, rank: 0 })); + } + } + + async searchAlbums({ + query, + limit = 20, + offset = 0, + }: SearchOptions): Promise { + if (!query || query.trim().length === 0) { + return []; + } + + const tsquery = this.queryToTsquery(query); + + try { + const results = await prisma.$queryRaw` + SELECT + a.id, + a.title, + a."artistId", + ar.name as "artistName", + a.year, + a."coverUrl", + GREATEST( + ts_rank(a.search_vector, to_tsquery('english', ${tsquery})), + ts_rank(ar.search_vector, to_tsquery('english', ${tsquery})) + ) AS rank + FROM "Album" a + LEFT JOIN "Artist" ar ON a."artistId" = ar.id + WHERE a.search_vector @@ to_tsquery('english', ${tsquery}) + OR ar.search_vector @@ to_tsquery('english', ${tsquery}) + ORDER BY rank DESC, a.title ASC + LIMIT ${limit} + OFFSET ${offset} + `; + + return results; + } catch (error) { + console.error("Album search error:", error); + // Fallback to LIKE query - search both album title and artist name + const results = await prisma.album.findMany({ + where: { + OR: [ + { + title: { + contains: query, + mode: "insensitive", + }, + }, + { + artist: { + name: { + contains: query, + mode: "insensitive", + }, + }, + }, + ], + }, + select: { + id: true, + title: true, + artistId: true, + year: true, + coverUrl: true, + artist: { + select: { + name: true, + }, + }, + }, + take: limit, + skip: offset, + orderBy: { + title: "asc", + }, + }); + + return results.map((r) => ({ + id: r.id, + title: r.title, + artistId: r.artistId, + artistName: r.artist.name, + year: r.year, + coverUrl: r.coverUrl, + rank: 0, + })); + } + } + + async searchTracks({ + query, + limit = 20, + offset = 0, + }: SearchOptions): Promise { + if (!query || query.trim().length === 0) { + return []; + } + + const tsquery = this.queryToTsquery(query); + + try { + const results = await prisma.$queryRaw` + SELECT + t.id, + t.title, + t."albumId", + t.duration, + a.title as "albumTitle", + a."artistId", + ar.name as "artistName", + ts_rank(t.search_vector, to_tsquery('english', ${tsquery})) AS rank + FROM "Track" t + LEFT JOIN "Album" a ON t."albumId" = a.id + LEFT JOIN "Artist" ar ON a."artistId" = ar.id + WHERE t.search_vector @@ to_tsquery('english', ${tsquery}) + ORDER BY rank DESC, t.title ASC + LIMIT ${limit} + OFFSET ${offset} + `; + + return results; + } catch (error) { + console.error("Track search error:", error); + // Fallback to LIKE query + const results = await prisma.track.findMany({ + where: { + title: { + contains: query, + mode: "insensitive", + }, + }, + select: { + id: true, + title: true, + albumId: true, + duration: true, + album: { + select: { + title: true, + artistId: true, + artist: { + select: { + name: true, + }, + }, + }, + }, + }, + take: limit, + skip: offset, + orderBy: { + title: "asc", + }, + }); + + return results.map((r) => ({ + id: r.id, + title: r.title, + albumId: r.albumId, + albumTitle: r.album.title, + artistId: r.album.artistId, + artistName: r.album.artist.name, + duration: r.duration, + rank: 0, + })); + } + } + + async searchPodcasts({ + query, + limit = 20, + offset = 0, + }: SearchOptions): Promise { + if (!query || query.trim().length === 0) { + return []; + } + + // Simple LIKE search for podcasts (no full-text search vector on podcasts yet) + try { + const results = await prisma.podcast.findMany({ + where: { + OR: [ + { + title: { + contains: query, + mode: "insensitive", + }, + }, + { + author: { + contains: query, + mode: "insensitive", + }, + }, + { + description: { + contains: query, + mode: "insensitive", + }, + }, + ], + }, + select: { + id: true, + title: true, + author: true, + description: true, + imageUrl: true, + episodeCount: true, + }, + take: limit, + skip: offset, + orderBy: { + title: "asc", + }, + }); + + return results; + } catch (error) { + console.error("Podcast search error:", error); + return []; + } + } + + async searchAll({ query, limit = 10 }: SearchOptions) { + if (!query || query.trim().length === 0) { + return { + artists: [], + albums: [], + tracks: [], + podcasts: [], + }; + } + + // Check Redis cache first + const cacheKey = `search:all:${query}:${limit}`; + try { + const cached = await redisClient.get(cacheKey); + if (cached) { + console.log(`[SEARCH] Cache HIT for query: "${query}"`); + return JSON.parse(cached); + } + } catch (err) { + console.warn("[SEARCH] Redis cache read error:", err); + } + + console.log( + `[SEARCH] Cache MISS for query: "${query}" - fetching from database` + ); + + const [artists, albums, tracks, podcasts] = await Promise.all([ + this.searchArtists({ query, limit }), + this.searchAlbums({ query, limit }), + this.searchTracks({ query, limit }), + this.searchPodcasts({ query, limit }), + ]); + + const results = { artists, albums, tracks, podcasts }; + + // Cache for 1 hour (search results don't change often) + try { + await redisClient.setEx(cacheKey, 3600, JSON.stringify(results)); + } catch (err) { + console.warn("[SEARCH] Redis cache write error:", err); + } + + return results; + } +} + +export const searchService = new SearchService(); diff --git a/backend/src/services/simpleDownloadManager.ts b/backend/src/services/simpleDownloadManager.ts new file mode 100644 index 0000000..d1361ed --- /dev/null +++ b/backend/src/services/simpleDownloadManager.ts @@ -0,0 +1,1684 @@ +/** + * Simple Download Manager (Refactored) + * + * Stateless download service that uses the database as the single source of truth. + * Handles album downloads with automatic retry, blocklisting, and completion tracking. + * No in-memory state - survives server restarts. + */ + +import { prisma } from "../utils/db"; +import { lidarrService, LidarrRelease } from "./lidarr"; +import { musicBrainzService } from "./musicbrainz"; +import { getSystemSettings } from "../utils/systemSettings"; +import { notificationService } from "./notificationService"; +import { sessionLog } from "../utils/playlistLogger"; +import axios from "axios"; +import * as crypto from "crypto"; + +// Generate a UUID v4 without external dependency +function generateCorrelationId(): string { + return crypto.randomUUID(); +} + +class SimpleDownloadManager { + private readonly DEFAULT_MAX_ATTEMPTS = 3; + // Increased timeouts for batch processing (Discovery requests 30+ albums at once) + private readonly IMPORT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes (large batches need more time) + private readonly PENDING_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes for pending (batch queuing) + + /** + * Get max retry attempts from user's discover config, fallback to default + */ + private async getMaxAttempts(userId: string): Promise { + try { + const config = await prisma.userDiscoverConfig.findUnique({ + where: { userId }, + }); + return config?.maxRetryAttempts || this.DEFAULT_MAX_ATTEMPTS; + } catch { + return this.DEFAULT_MAX_ATTEMPTS; + } + } + + /** + * Start a new download + * Returns the correlation ID for webhook matching + * @param isDiscovery - If true, tags the artist in Lidarr for discovery cleanup + */ + async startDownload( + jobId: string, + artistName: string, + albumTitle: string, + albumMbid: string, + userId: string, + isDiscovery: boolean = false + ): Promise<{ success: boolean; correlationId?: string; error?: string }> { + console.log(`\n Starting download: ${artistName} - ${albumTitle}${isDiscovery ? " (discovery)" : ""}`); + console.log(` Job ID: ${jobId}`); + console.log(` Album MBID: ${albumMbid}`); + + // Generate correlation ID for webhook matching + const correlationId = generateCorrelationId(); + + try { + // Fetch artist MBID from MusicBrainz using the album MBID + let artistMbid: string | undefined; + try { + console.log(` Fetching artist MBID from MusicBrainz...`); + const releaseGroup = await musicBrainzService.getReleaseGroup( + albumMbid + ); + + if (releaseGroup?.["artist-credit"]?.[0]?.artist?.id) { + artistMbid = releaseGroup["artist-credit"][0].artist.id; + console.log(` Found artist MBID: ${artistMbid}`); + } else { + console.warn( + ` Could not extract artist MBID from release group` + ); + } + } catch (mbError) { + console.error( + ` Failed to fetch artist MBID from MusicBrainz:`, + mbError + ); + } + + // Add album to Lidarr (with discovery tag if this is a discovery download) + const result = await lidarrService.addAlbum( + albumMbid, + artistName, + albumTitle, + "/music", + artistMbid, + isDiscovery + ); + + if (!result) { + throw new Error( + "Failed to add album to Lidarr - album not found" + ); + } + + console.log(` Album queued in Lidarr (ID: ${result.id})`); + + // Lidarr may have matched by name and returned a different MBID + const actualLidarrMbid = result.foreignAlbumId; + if (actualLidarrMbid && actualLidarrMbid !== albumMbid) { + console.log( + ` MBID mismatch - original: ${albumMbid}, Lidarr: ${actualLidarrMbid}` + ); + } + + // Update job with all tracking information + // IMPORTANT: Preserve existing metadata (especially tier/similarity from discovery jobs) + const now = new Date(); + const existingJob = await prisma.downloadJob.findUnique({ + where: { id: jobId }, + select: { metadata: true }, + }); + const existingMetadata = (existingJob?.metadata as any) || {}; + + await prisma.downloadJob.update({ + where: { id: jobId }, + data: { + correlationId, // Unique ID for webhook matching + status: "processing", + startedAt: now, // For timeout tracking (if field exists) + lidarrAlbumId: result.id, // Store Lidarr album ID for retry/cleanup + artistMbid: artistMbid, // Store artist MBID for same-artist fallback + attempts: 1, + metadata: { + ...existingMetadata, // Preserve tier, similarity, etc. + albumTitle, + artistName, + artistMbid, + albumMbid, // Original requested MBID + lidarrMbid: actualLidarrMbid, // Actual Lidarr MBID (may differ) + downloadType: existingMetadata.downloadType || "library", + startedAt: now.toISOString(), // Backup in metadata for timeout tracking + }, + }, + }); + + console.log( + ` Download started with correlation ID: ${correlationId}` + ); + return { success: true, correlationId }; + } catch (error: any) { + console.error(` Failed to start download:`, error.message); + + // Get the job to check if it's a discovery job + const job = await prisma.downloadJob.findUnique({ + where: { id: jobId }, + }); + + // If album wasn't found, try same-artist fallback ONLY for non-discovery jobs + // Discovery jobs should find NEW artists via the discovery system instead + if (job && error.message?.includes("album not found")) { + if (job.discoveryBatchId) { + console.log(` Album not found - Discovery job, skipping same-artist fallback`); + console.log(` Discovery system will find a different artist instead`); + } else { + console.log(` Album not found - trying same-artist fallback...`); + + // Use the new tryNextAlbumFromArtist approach instead of findReplacementAlbum + const metadata = (job.metadata as any) || {}; + const artistMbid = job.artistMbid || metadata.artistMbid; + + if (artistMbid) { + const fallbackResult = await this.tryNextAlbumFromArtist( + { ...job, metadata }, + "Album not found in Lidarr" + ); + + if (fallbackResult.retried && fallbackResult.jobId) { + return { success: true, replacedWith: fallbackResult.jobId }; + } + } + } + } + + // No replacement found - mark as failed + await prisma.downloadJob.update({ + where: { id: jobId }, + data: { + correlationId, + status: "failed", + error: error.message || "Failed to add album to Lidarr", + completedAt: new Date(), + }, + }); + + // Check batch completion for discovery jobs + if (job?.discoveryBatchId) { + const { discoverWeeklyService } = await import("./discoverWeekly"); + await discoverWeeklyService.checkBatchCompletion(job.discoveryBatchId); + } + + return { success: false, error: error.message }; + } + } + + /** + * Handle download grabbed event (from webhook) + * Links the Lidarr downloadId to our job + * + * IMPORTANT: One logical album = one job, regardless of MBID. + * MBIDs can differ between MusicBrainz and Lidarr, but artist+album name is canonical. + */ + async onDownloadGrabbed( + downloadId: string, + albumMbid: string, + albumTitle: string, + artistName: string, + lidarrAlbumId: number + ): Promise<{ matched: boolean; jobId?: string }> { + console.log(`[DOWNLOAD] Grabbed: ${artistName} - ${albumTitle}`); + console.log(` Download ID: ${downloadId}`); + console.log(` Album MBID: ${albumMbid}`); + console.log(` Lidarr Album ID: ${lidarrAlbumId}`); + + // Get ALL active jobs (pending + processing) for matching + // Include pending because job might not have transitioned to processing yet + const activeJobs = await prisma.downloadJob.findMany({ + where: { + status: { in: ["pending", "processing"] }, + }, + }); + + console.log( + ` Found ${activeJobs.length} active job(s) to match against` + ); + + let job: (typeof activeJobs)[0] | undefined; + + // Normalize artist/album for name-based matching + const normalizedArtist = artistName?.toLowerCase().trim() || ""; + const normalizedAlbum = albumTitle?.toLowerCase().trim() || ""; + + // Strategy 1: Match by targetMbid (exact MBID match) + job = activeJobs.find( + (j) => j.targetMbid === albumMbid && !j.lidarrRef + ); + if (job) { + console.log(` Matched by targetMbid`); + } + + // Strategy 2: Match by lidarrMbid in metadata + if (!job) { + job = activeJobs.find((j) => { + const metadata = j.metadata as any; + return metadata?.lidarrMbid === albumMbid && !j.lidarrRef; + }); + if (job) { + console.log(` Matched by lidarrMbid`); + } + } + + // Strategy 3: Match by lidarrAlbumId (stored when download started) + if (!job && lidarrAlbumId > 0) { + job = activeJobs.find((j) => { + const metadata = j.metadata as any; + return ( + (j as any).lidarrAlbumId === lidarrAlbumId || + metadata?.lidarrAlbumId === lidarrAlbumId + ); + }); + if (job) { + console.log(` Matched by lidarrAlbumId`); + } + } + + // Strategy 4: Match by artist + album title in metadata (CANONICAL - most important) + // This handles MBID mismatches between MusicBrainz and Lidarr + if (!job && normalizedArtist && normalizedAlbum) { + job = activeJobs.find((j) => { + if (j.lidarrRef) return false; // Already linked to a different download + const metadata = j.metadata as any; + const candidateArtist = metadata?.artistName?.toLowerCase().trim() || ""; + const candidateAlbum = metadata?.albumTitle?.toLowerCase().trim() || ""; + return ( + candidateArtist === normalizedArtist && + candidateAlbum === normalizedAlbum + ); + }); + if (job) { + console.log(` Matched by artist/album title in metadata`); + } + } + + // Strategy 5: Match by subject field (format: "Artist - Album") + if (!job && normalizedArtist && normalizedAlbum) { + job = activeJobs.find((j) => { + if (j.lidarrRef) return false; // Already linked + const subject = j.subject?.toLowerCase().trim() || ""; + // Check if subject contains BOTH artist AND album (more precise) + return ( + subject.includes(normalizedArtist) && + subject.includes(normalizedAlbum) + ); + }); + if (job) { + console.log(` Matched by subject field`); + } + } + + // Strategy 6: For retries - update job that already has a different lidarrRef + if (!job && lidarrAlbumId > 0) { + job = activeJobs.find((j) => { + const metadata = j.metadata as any; + return ( + ((j as any).lidarrAlbumId === lidarrAlbumId || + metadata?.lidarrAlbumId === lidarrAlbumId) && + j.lidarrRef !== null + ); + }); + if (job) { + console.log(` Matched retry by lidarrAlbumId (updating lidarrRef)`); + } + } + + if (!job) { + // Before creating a new job, do one final check: search ALL active jobs by name + // This catches jobs that might have been created with different casing or formatting + console.log(` No match in active jobs with first pass - doing thorough name search...`); + + // Search all active jobs (including ones we might have filtered out) + for (const j of activeJobs) { + if (j.lidarrRef) continue; // Already linked + + const metadata = j.metadata as any; + const candidateArtist = metadata?.artistName?.toLowerCase().trim() || ""; + const candidateAlbum = metadata?.albumTitle?.toLowerCase().trim() || ""; + const subject = j.subject?.toLowerCase().trim() || ""; + + // More lenient matching - check metadata OR subject + const artistMatches = + candidateArtist === normalizedArtist || + (normalizedArtist && subject.includes(normalizedArtist)); + const albumMatches = + candidateAlbum === normalizedAlbum || + (normalizedAlbum && subject.includes(normalizedAlbum)); + + if (artistMatches && albumMatches) { + console.log(` Found existing job by thorough name search: ${j.id}`); + job = j; + break; + } + } + } + + if (!job) { + // Still no match - this is truly an external download or timing issue + // Create a tracking job, but first check we're not creating a duplicate + console.log(` No matching job found - checking for duplicates before creating tracking job`); + + // Check if there's already a tracking job for this exact download + const existingTrackingJob = await prisma.downloadJob.findFirst({ + where: { + lidarrRef: downloadId, + }, + }); + + if (existingTrackingJob) { + console.log(` Tracking job already exists: ${existingTrackingJob.id}`); + return { matched: true, jobId: existingTrackingJob.id }; + } + + // Check if there's a job for this artist+album that we somehow missed + const duplicateCheck = await prisma.downloadJob.findFirst({ + where: { + status: { in: ["pending", "processing"] }, + OR: [ + { targetMbid: albumMbid }, + { lidarrAlbumId: lidarrAlbumId > 0 ? lidarrAlbumId : undefined }, + ], + }, + }); + + if (duplicateCheck) { + console.log(` Found job by MBID/lidarrAlbumId: ${duplicateCheck.id} - linking instead of creating new`); + job = duplicateCheck; + } + } + + if (!job) { + // Truly no existing job - create tracking job for retry support + console.log(` Creating tracking job for untracked download`); + + try { + // Find the user from a recent artist download request + const recentJob = await prisma.downloadJob.findFirst({ + where: { + type: "artist", + status: { in: ["pending", "processing", "completed"] }, + metadata: { + path: ["artistName"], + string_contains: artistName, + }, + }, + orderBy: { createdAt: "desc" }, + }); + + const userId = recentJob?.userId; + + if (userId) { + const newJob = await prisma.downloadJob.create({ + data: { + userId, + subject: `${artistName} - ${albumTitle}`, + type: "album", + targetMbid: albumMbid, + status: "processing", + lidarrRef: downloadId, + lidarrAlbumId, + attempts: 1, + metadata: { + artistName, + albumTitle, + downloadId, + grabbedAt: new Date().toISOString(), + source: "lidarr-auto-grab", + }, + }, + }); + console.log(` Created tracking job: ${newJob.id}`); + return { matched: true, jobId: newJob.id }; + } else { + console.log(` Could not determine user, skipping job creation`); + return { matched: false }; + } + } catch (error: any) { + console.log(` Failed to create tracking job: ${error.message}`); + return { matched: false }; + } + } + + // Update job with Lidarr reference and ensure status is processing + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "processing", // Ensure status is processing (might have been pending) + lidarrRef: downloadId, + lidarrAlbumId, + targetMbid: job.targetMbid || albumMbid, // Keep original or use Lidarr's + metadata: { + ...((job.metadata as any) || {}), + downloadId, + lidarrMbid: albumMbid, // Store Lidarr's MBID for future matching + grabbedAt: new Date().toISOString(), + }, + }, + }); + + console.log(` Linked to job: ${job.id}`); + return { matched: true, jobId: job.id }; + } + + /** + * Handle download complete event (from webhook) + * + * IMPORTANT: One logical album = one job. Match by name if MBID doesn't match. + */ + async onDownloadComplete( + downloadId: string, + albumMbid?: string, + artistName?: string, + albumTitle?: string, + lidarrAlbumId?: number + ): Promise<{ jobId?: string; batchId?: string; downloadBatchId?: string }> { + console.log(`\n[COMPLETE] Download completed: ${downloadId}`); + if (albumMbid) console.log(` Album MBID: ${albumMbid}`); + if (lidarrAlbumId) console.log(` Lidarr Album ID: ${lidarrAlbumId}`); + if (artistName && albumTitle) + console.log(` Album: ${artistName} - ${albumTitle}`); + + // Get ALL active jobs (pending + processing) for matching + const activeJobs = await prisma.downloadJob.findMany({ + where: { status: { in: ["pending", "processing"] } }, + }); + + console.log( + ` Found ${activeJobs.length} active job(s) to match against` + ); + + // Normalize for name matching + const normalizedArtist = artistName?.toLowerCase().trim() || ""; + const normalizedAlbum = albumTitle?.toLowerCase().trim() || ""; + + let job: (typeof activeJobs)[0] | undefined; + let matchedJobs: (typeof activeJobs) = []; + + // Strategy 1: Find job by lidarrRef (most reliable) + job = activeJobs.find((j) => j.lidarrRef === downloadId); + if (job) console.log(` Matched by lidarrRef`); + + // Strategy 2: Find job by lidarrAlbumId + if (!job && lidarrAlbumId) { + job = activeJobs.find((j) => j.lidarrAlbumId === lidarrAlbumId); + if (job) console.log(` Matched by lidarrAlbumId`); + } + + // Strategy 3: Match by previousDownloadIds (for retried downloads) + if (!job) { + job = activeJobs.find((j) => { + const metadata = j.metadata as any; + const prevIds = metadata?.previousDownloadIds as string[] | undefined; + return prevIds?.includes(downloadId); + }); + if (job) console.log(` Matched by previousDownloadIds`); + } + + // Strategy 4: Match by MBID (targetMbid or lidarrMbid in metadata) + if (!job && albumMbid) { + job = activeJobs.find((j) => j.targetMbid === albumMbid); + if (job) { + console.log(` Matched by targetMbid`); + } else { + job = activeJobs.find((j) => { + const metadata = j.metadata as any; + return metadata?.lidarrMbid === albumMbid; + }); + if (job) console.log(` Matched by lidarrMbid in metadata`); + } + } + + // Strategy 5: Match by artist+album name (CANONICAL - handles MBID mismatches) + if (!job && normalizedArtist && normalizedAlbum) { + // Find ALL jobs matching this artist+album (we'll dedupe after) + matchedJobs = activeJobs.filter((j) => { + const metadata = j.metadata as any; + const candidateArtist = metadata?.artistName?.toLowerCase().trim() || ""; + const candidateAlbum = metadata?.albumTitle?.toLowerCase().trim() || ""; + const subject = j.subject?.toLowerCase().trim() || ""; + + // Match by metadata or subject + const metaMatch = candidateArtist === normalizedArtist && candidateAlbum === normalizedAlbum; + const subjectMatch = subject.includes(normalizedArtist) && subject.includes(normalizedAlbum); + + return metaMatch || subjectMatch; + }); + + if (matchedJobs.length > 0) { + // Pick the first one (oldest), will clean up duplicates below + job = matchedJobs[0]; + console.log(` Matched by artist/album name (found ${matchedJobs.length} matching job(s))`); + } + } + + // Strategy 6: Match by subject containing artist (last resort) + if (!job && normalizedArtist) { + job = activeJobs.find((j) => { + const subject = j.subject?.toLowerCase().trim() || ""; + return subject.includes(normalizedArtist); + }); + if (job) console.log(` Matched by subject containing artist`); + } + + if (!job) { + console.log(` No matching job found for downloadId: ${downloadId}`); + return {}; + } + + // Clean up duplicate jobs for the same artist+album + // Mark extras as completed too (they're the same logical download) + // Always search for duplicates, regardless of how we found the primary job + const jobMeta = job.metadata as any; + const jobArtist = jobMeta?.artistName?.toLowerCase().trim() || ""; + const jobAlbum = jobMeta?.albumTitle?.toLowerCase().trim() || ""; + const jobSubject = job.subject?.toLowerCase().trim() || ""; + + const duplicateJobs = activeJobs.filter((j) => { + if (j.id === job.id) return false; // Skip the matched job + + const meta = j.metadata as any; + const candArtist = meta?.artistName?.toLowerCase().trim() || ""; + const candAlbum = meta?.albumTitle?.toLowerCase().trim() || ""; + const candSubject = j.subject?.toLowerCase().trim() || ""; + + // Match by metadata + if (jobArtist && jobAlbum && candArtist === jobArtist && candAlbum === jobAlbum) { + return true; + } + + // Match by subject + if (jobSubject && candSubject === jobSubject) { + return true; + } + + // Match if subjects contain both artist and album + if (jobArtist && jobAlbum && candSubject.includes(jobArtist) && candSubject.includes(jobAlbum)) { + return true; + } + + return false; + }); + + if (duplicateJobs.length > 0) { + console.log(` Found ${duplicateJobs.length} duplicate job(s) for same album - marking as completed`); + const duplicateIds = duplicateJobs.map(j => j.id); + await prisma.downloadJob.updateMany({ + where: { id: { in: duplicateIds } }, + data: { + status: "completed", + completedAt: new Date(), + error: null, + }, + }); + } + + // Mark job as completed (clear any previous error messages) + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "completed", + completedAt: new Date(), + error: null, // Clear any timeout errors since download succeeded + metadata: { + ...((job.metadata as any) || {}), + completedAt: new Date().toISOString(), + }, + }, + }); + + console.log(` Job ${job.id} marked complete`); + + // Send notification for completed download (skip for discovery/import batches) + // Also skip if notification was already sent (dedup for same artist+album) + const meta = job.metadata as any; + const isDiscovery = meta?.downloadType === "discovery"; + const isSpotifyImport = !!meta?.spotifyImportJobId; + const notificationAlreadySent = meta?.notificationSent === true; + + if (!isDiscovery && !isSpotifyImport && !notificationAlreadySent) { + try { + await notificationService.notifyDownloadComplete( + job.userId, + job.subject, + undefined, + meta?.artistId + ); + + // Mark notification as sent to prevent duplicates + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + metadata: { + ...meta, + notificationSent: true, + }, + }, + }); + } catch (notifError) { + console.error("Failed to send download notification:", notifError); + } + } + + const metadata = job.metadata as any; + const downloadBatchId = metadata?.batchId as string | undefined; + const spotifyImportJobId = metadata?.spotifyImportJobId as string | undefined; + + // Check if part of discovery batch + if (job.discoveryBatchId) { + console.log(` Part of Discovery batch: ${job.discoveryBatchId}`); + // Use dynamic import to avoid circular dependency + const { discoverWeeklyService } = await import("./discoverWeekly"); + await discoverWeeklyService.checkBatchCompletion( + job.discoveryBatchId + ); + return { + jobId: job.id, + batchId: job.discoveryBatchId, + downloadBatchId, + }; + } + + // Check if part of Spotify import + if (spotifyImportJobId) { + console.log(` Part of Spotify Import: ${spotifyImportJobId}`); + // Use dynamic import to avoid circular dependency + const { spotifyImportService } = await import("./spotifyImport"); + await spotifyImportService.checkImportCompletion(spotifyImportJobId); + return { + jobId: job.id, + spotifyImportJobId, + downloadBatchId, + }; + } + + // Check if part of download batch (artist download) + if (downloadBatchId) { + console.log(` Part of download batch: ${downloadBatchId}`); + } + + return { jobId: job.id, downloadBatchId }; + } + + // Track recently processed failure events to prevent duplicate handling + private processedFailures = new Map(); + private readonly FAILURE_DEDUP_WINDOW_MS = 30000; // 30 seconds + + /** + * Handle import failure - LET LIDARR HANDLE RELEASE ITERATION + * + * Strategy: + * 1. Blocklist the failed release with skipRedownload=false (Lidarr searches for alternatives) + * 2. Track the failure but DON'T limit retries - let Lidarr exhaust all releases + * 3. Only intervene when Lidarr has NO more releases (detected via stale job timeout) + * 4. At that point, try a different album from the same artist + */ + async onImportFailed( + downloadId: string, + reason: string, + albumMbid?: string + ): Promise<{ retried: boolean; failed: boolean; jobId?: string }> { + console.log(`\n[RETRY] Import failed: ${downloadId}`); + console.log(` Reason: ${reason}`); + + // Deduplicate failure events - same downloadId within 30 seconds + const now = Date.now(); + const lastProcessed = this.processedFailures.get(downloadId); + if ( + lastProcessed && + now - lastProcessed < this.FAILURE_DEDUP_WINDOW_MS + ) { + console.log( + ` Duplicate failure event (within ${ + this.FAILURE_DEDUP_WINDOW_MS / 1000 + }s), skipping` + ); + return { retried: false, failed: false }; + } + this.processedFailures.set(downloadId, now); + + // Clean up old entries periodically + if (this.processedFailures.size > 100) { + for (const [id, time] of this.processedFailures) { + if (now - time > this.FAILURE_DEDUP_WINDOW_MS * 2) { + this.processedFailures.delete(id); + } + } + } + + // Find all processing jobs to match against + const processingJobs = await prisma.downloadJob.findMany({ + where: { status: "processing" }, + }); + + let job: (typeof processingJobs)[0] | undefined; + + // Strategy 1: Match by current lidarrRef + job = processingJobs.find((j) => j.lidarrRef === downloadId); + if (job) console.log(` Matched by lidarrRef`); + + // Strategy 2: Match by previousDownloadIds in metadata + if (!job) { + job = processingJobs.find((j) => { + const metadata = j.metadata as any; + const prevIds = metadata?.previousDownloadIds as + | string[] + | undefined; + return prevIds?.includes(downloadId); + }); + if (job) console.log(` Matched by previousDownloadIds`); + } + + // Strategy 3: Match by MBID + if (!job && albumMbid) { + job = processingJobs.find((j) => j.targetMbid === albumMbid); + if (job) console.log(` Matched by albumMbid`); + } + + if (!job) { + console.log( + ` No matching job found - cleaning up Lidarr queue anyway` + ); + // Still try to remove from Lidarr queue to prevent it from being stuck + await this.removeFromLidarrQueue(downloadId); + return { retried: false, failed: false }; + } + + console.log(` Found job: ${job.id}`); + console.log(` Album: ${job.subject}`); + + // ============================================ + // LET LIDARR HANDLE RELEASE ITERATION + // ============================================ + // Blocklist current release and let Lidarr search for alternatives + // skipRedownload=false means Lidarr will automatically search for another release + + const metadata = (job.metadata as any) || {}; + const failureCount = (metadata.failureCount || 0) + 1; + const previousDownloadIds = metadata.previousDownloadIds || []; + if (downloadId && !previousDownloadIds.includes(downloadId)) { + previousDownloadIds.push(downloadId); + } + + // Update job with failure tracking (no retry limit - let Lidarr exhaust options) + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + lidarrRef: null, // Clear - we'll get a new one from Lidarr's next grab + metadata: { + ...metadata, + failureCount, + lastError: reason, + lastFailureAt: new Date().toISOString(), + previousDownloadIds, + }, + }, + }); + + console.log(` Failure #${failureCount} - blocklisting and letting Lidarr find alternative`); + + // Blocklist with skipRedownload=false so Lidarr searches for alternatives + await this.removeFromLidarrQueue(downloadId); + + // For Spotify Import jobs, check if this failure completes the import + // (Unlike regular downloads, we don't do fallback, so failure might mean completion) + if (metadata.spotifyImportJobId) { + // Don't check immediately - let Lidarr try alternative releases first + // The stale job cleanup will eventually mark it as exhausted + } + + return { retried: true, failed: false, jobId: job.id }; + } + + /** + * Try the next album from the same artist when current album is exhausted + * This is called when all releases for an album have been tried + * + * IMPORTANT: + * - For Discovery Weekly jobs, we DON'T do same-artist fallback. + * Discovery should find NEW artists, not more albums from the same artist. + * - For Spotify Import jobs, we DON'T do same-artist fallback. + * User wants EXACT playlist, not substitutes. + */ + private async tryNextAlbumFromArtist( + job: any, + reason: string + ): Promise<{ retried: boolean; failed: boolean; jobId?: string }> { + const metadata = (job.metadata as any) || {}; + const artistMbid = job.artistMbid || metadata.artistMbid; + const artistName = metadata.artistName; + + // CRITICAL: For Discovery Weekly, DON'T try same-artist fallback + // Discovery should prioritize ARTIST DIVERSITY - let the discovery system + // find a completely different artist instead + if (job.discoveryBatchId) { + console.log(`[RETRY] Discovery job - skipping same-artist fallback (diversity enforced)`); + console.log(` Discovery should find NEW artists, not more from: ${artistName}`); + return await this.markJobExhausted(job, reason); + } + + // CRITICAL: For Spotify Import, DON'T try same-artist fallback + // User wants the EXACT playlist, not substitutes from same artist + if (metadata.spotifyImportJobId || metadata.downloadType === "spotify_import" || metadata.noFallback) { + console.log(`[RETRY] Spotify Import job - skipping fallback (exact match required)`); + console.log(` User wants exact album: ${job.subject}`); + + // Mark as failed and trigger completion check + const result = await this.markJobExhausted(job, reason); + + // Check if import is complete + if (metadata.spotifyImportJobId) { + const { spotifyImportService } = await import("./spotifyImport"); + await spotifyImportService.checkImportCompletion(metadata.spotifyImportJobId); + } + + return result; + } + + if (!artistMbid) { + console.log(` No artistMbid - cannot try other albums from same artist`); + return await this.markJobExhausted(job, reason); + } + + console.log(`[RETRY] Trying other albums from artist: ${artistName || artistMbid}`); + + try { + // Get albums available in LIDARR for this artist (not MusicBrainz) + // MusicBrainz has many obscure albums (bootlegs, live recordings) that Lidarr can't find + const lidarrAlbums = await lidarrService.getArtistAlbums(artistMbid); + + if (!lidarrAlbums || lidarrAlbums.length === 0) { + console.log(` No albums found in Lidarr for artist`); + return await this.markJobExhausted(job, reason); + } + + console.log(` Found ${lidarrAlbums.length} albums in Lidarr for artist`); + + // Get albums we've already tried + const triedAlbumMbids = new Set(); + + // Check for other jobs with same artist + const artistJobs = await prisma.downloadJob.findMany({ + where: { + artistMbid: artistMbid, + status: { in: ["processing", "completed", "failed", "exhausted"] }, + }, + }); + artistJobs.forEach((j: any) => { + triedAlbumMbids.add(j.targetMbid); + }); + + // Also add current job's album + triedAlbumMbids.add(job.targetMbid); + + // Filter to untried albums that exist in Lidarr + const untriedAlbums = lidarrAlbums.filter( + (album: any) => !triedAlbumMbids.has(album.foreignAlbumId) + ); + + console.log(` Untried albums in Lidarr: ${untriedAlbums.length}`); + + if (untriedAlbums.length === 0) { + console.log(` All Lidarr albums from artist exhausted`); + return await this.markJobExhausted(job, reason); + } + + // Pick the first untried album (prioritize studio albums over singles/EPs if possible) + const studioAlbums = untriedAlbums.filter((a: any) => + a.albumType?.toLowerCase() === 'album' || + !a.albumType + ); + const nextAlbum = studioAlbums.length > 0 ? studioAlbums[0] : untriedAlbums[0]; + console.log(`[RETRY] Trying next album from same artist: ${nextAlbum.title}`); + + // Mark current job as exhausted (not failed - we're continuing with same artist) + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "exhausted", + error: `All releases exhausted - trying: ${nextAlbum.title}`, + completedAt: new Date(), + }, + }); + + // Use Lidarr's foreignAlbumId (MBID) for the new job + const albumMbid = nextAlbum.foreignAlbumId; + + // Create new job for the next album + const newJob = await prisma.downloadJob.create({ + data: { + userId: job.userId, + subject: `${artistName || 'Unknown'} - ${nextAlbum.title}`, + type: "album", + targetMbid: albumMbid, + status: "pending", + discoveryBatchId: job.discoveryBatchId, + artistMbid: artistMbid, + metadata: { + artistName: artistName, + artistMbid: artistMbid, + albumTitle: nextAlbum.title, + albumMbid: albumMbid, + lidarrAlbumId: nextAlbum.id, // Store Lidarr album ID for faster lookup + sameArtistFallback: true, + originalJobId: job.id, + downloadType: metadata.downloadType || "library", + rootFolderPath: metadata.rootFolderPath || "/music", + }, + }, + }); + + console.log(` Created fallback job: ${newJob.id}`); + + // Start the download + const result = await this.startDownload( + newJob.id, + artistName || "Unknown Artist", + nextAlbum.title, + albumMbid, + job.userId + ); + + if (result.success) { + console.log(` Same-artist fallback download started`); + return { retried: true, failed: false, jobId: newJob.id }; + } else { + console.log(` Same-artist fallback failed to start: ${result.error}`); + // The new job will be marked as failed by startDownload + return { retried: false, failed: true, jobId: newJob.id }; + } + } catch (error: any) { + console.error(` Error trying same-artist fallback: ${error.message}`); + return await this.markJobExhausted(job, reason); + } + } + + /** + * Mark a job as exhausted (all releases and same-artist albums tried) + * + * IMPORTANT: Before failing, check if another job for the same album already succeeded. + * This handles race conditions where duplicates exist and one succeeds. + */ + private async markJobExhausted( + job: any, + reason: string + ): Promise<{ retried: boolean; failed: boolean; jobId?: string }> { + console.log(`[RETRY] Job fully exhausted: ${job.id}`); + + const meta = job.metadata as any; + const artistName = meta?.artistName?.toLowerCase().trim() || ""; + const albumTitle = meta?.albumTitle?.toLowerCase().trim() || ""; + + // Before marking as failed, check if another job for the same album already SUCCEEDED + // This handles duplicate job scenarios + if (artistName && albumTitle) { + const completedDuplicate = await prisma.downloadJob.findFirst({ + where: { + id: { not: job.id }, + status: "completed", + }, + }); + + if (completedDuplicate) { + const dupMeta = completedDuplicate.metadata as any; + const dupArtist = dupMeta?.artistName?.toLowerCase().trim() || ""; + const dupAlbum = dupMeta?.albumTitle?.toLowerCase().trim() || ""; + + if (dupArtist === artistName && dupAlbum === albumTitle) { + console.log(` Found completed duplicate job ${completedDuplicate.id} - marking this as completed too`); + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "completed", + completedAt: new Date(), + error: null, + metadata: { + ...meta, + mergedWithJob: completedDuplicate.id, + }, + }, + }); + return { retried: false, failed: false, jobId: job.id }; + } + } + } + + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "failed", + error: `All releases and albums exhausted: ${reason}`, + completedAt: new Date(), + }, + }); + + // Check batch completion for discovery jobs + if (job.discoveryBatchId) { + const { discoverWeeklyService } = await import("./discoverWeekly"); + await discoverWeeklyService.checkBatchCompletion(job.discoveryBatchId); + } + + // Send failure notification ONLY if: + // 1. Not discovery/spotify import + // 2. Notification not already sent for this job + // 3. No other job for the same album has already notified + const isDiscovery = meta?.downloadType === "discovery"; + const isSpotifyImport = !!meta?.spotifyImportJobId; + const notificationAlreadySent = meta?.notificationSent === true; + + if (!isDiscovery && !isSpotifyImport && !notificationAlreadySent) { + // Check if any OTHER job for this album already sent a notification + const otherNotified = await prisma.downloadJob.findFirst({ + where: { + id: { not: job.id }, + userId: job.userId, + metadata: { + path: ["artistName"], + string_contains: meta?.artistName || "", + }, + }, + }); + + let skipNotification = false; + if (otherNotified) { + const otherMeta = otherNotified.metadata as any; + if (otherMeta?.notificationSent && + otherMeta?.albumTitle?.toLowerCase() === albumTitle) { + skipNotification = true; + console.log(` Skipping notification - another job already notified for this album`); + } + } + + if (!skipNotification) { + try { + await notificationService.notifyDownloadFailed( + job.userId, + job.subject, + reason + ); + + // Mark notification as sent + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + metadata: { + ...meta, + notificationSent: true, + }, + }, + }); + } catch (notifError) { + console.error("Failed to send failure notification:", notifError); + } + } + } + + return { retried: false, failed: true, jobId: job.id }; + } + // Timeout for "no sources" - if Lidarr hasn't grabbed anything after searching + private readonly NO_SOURCE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes (indexer searches can be slow) + + /** + * Mark stale jobs as failed (called by cleanup job) + * - Pending jobs (never started) timeout after 3 minutes = "download never started" + * - Processing jobs with no lidarrRef (never grabbed) timeout after 2 minutes = "no sources found" + * - Processing jobs with lidarrRef (grabbed but not imported) timeout after 5 minutes = "import failed" + */ + async markStaleJobsAsFailed(): Promise { + const pendingCutoff = new Date(Date.now() - this.PENDING_TIMEOUT_MS); // 30 minutes for pending (batch processing) + const noSourceCutoff = new Date(Date.now() - this.NO_SOURCE_TIMEOUT_MS); + const importCutoff = new Date(Date.now() - this.IMPORT_TIMEOUT_MS); + + // Find all pending and processing jobs + const activeJobs = await prisma.downloadJob.findMany({ + where: { status: { in: ["pending", "processing"] } }, + }); + + // Log to session for debugging Spotify imports + if (activeJobs.length > 0) { + const spotifyJobs = activeJobs.filter(j => j.id.startsWith("spotify_")); + if (spotifyJobs.length > 0) { + sessionLog('CLEANUP', `Checking ${activeJobs.length} active jobs (${spotifyJobs.length} Spotify import)`); + } + } + + // Separate pending from processing + const pendingJobs = activeJobs.filter(j => j.status === "pending"); + const processingJobs = activeJobs.filter(j => j.status === "processing"); + + // Handle old pending jobs first (they never started) + const stalePendingJobs = pendingJobs.filter(job => job.createdAt < pendingCutoff); + + if (stalePendingJobs.length > 0) { + console.log(`\n⏰ Found ${stalePendingJobs.length} stuck PENDING jobs (never started)`); + sessionLog('CLEANUP', `Found ${stalePendingJobs.length} stuck PENDING jobs`); + + for (const job of stalePendingJobs) { + console.log(` Timing out: ${job.subject} (never started - ${Math.round((Date.now() - job.createdAt.getTime()) / 60000)}m old)`); + + // Mark as failed + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "failed", + error: "Download never started - timed out", + completedAt: new Date(), + }, + }); + + // Check batch completion for discovery jobs + if (job.discoveryBatchId) { + const { discoverWeeklyService } = await import("./discoverWeekly"); + await discoverWeeklyService.checkBatchCompletion(job.discoveryBatchId); + } + } + } + + if (processingJobs.length === 0) { + return 0; + } + + const staleJobs: typeof processingJobs = []; + + // Import lidarr service for active download check + const { isDownloadActive } = await import("./lidarr"); + + for (const job of processingJobs) { + const metadata = job.metadata as any; + const startedAt = metadata?.startedAt + ? new Date(metadata.startedAt) + : job.createdAt; + + // Skip Soulseek jobs - they complete immediately with direct slsk-client + // Old SLSKD jobs used source: "slskd", new direct jobs use source: "soulseek_direct" + if (metadata?.source === "slskd" || metadata?.source === "soulseek_direct") { + console.log(` ${job.subject}: Soulseek download, skipping stale check`); + sessionLog('CLEANUP', `Skipping Soulseek job: ${job.subject} (status: ${job.status})`); + continue; + } + + // Jobs without lidarrRef = Lidarr never grabbed = no sources found + if (!job.lidarrRef) { + if (startedAt < noSourceCutoff) { + staleJobs.push(job); + } + } else { + // Jobs with lidarrRef = grabbed but potentially still downloading + if (startedAt < importCutoff) { + // Check if Lidarr is still actively downloading before timing out + const downloadStatus = await isDownloadActive(job.lidarrRef); + + if (downloadStatus.active) { + // Still downloading - extend the timeout, don't mark as stale + console.log(` ${job.subject}: Still downloading (${downloadStatus.progress || 0}%), extending timeout`); + + // Update the startedAt to extend the timeout + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + metadata: { + ...metadata, + startedAt: new Date().toISOString(), + extendedTimeout: true, + } + } + }); + } else { + // Not actively downloading - mark as stale + staleJobs.push(job); + } + } + } + } + + if (staleJobs.length === 0) { + return 0; + } + + console.log(`\n⏰ Found ${staleJobs.length} stale download jobs`); + sessionLog('CLEANUP', `Found ${staleJobs.length} stale jobs to mark as failed`); + + // Track unique batch IDs to check + const batchIds = new Set(); + const downloadBatchIds = new Set(); + + for (const job of staleJobs) { + const hasLidarrRef = !!job.lidarrRef; + const errorMessage = hasLidarrRef + ? `Import failed - download stuck for ${ + this.IMPORT_TIMEOUT_MS / 60000 + } minutes` + : `No sources found - no indexer results`; + + console.log( + ` Timing out: ${job.subject} (${ + hasLidarrRef ? "stuck import" : "no sources" + })` + ); + sessionLog('CLEANUP', `Marking stale: ${job.subject} - ${errorMessage}`); + + const metadata = (job.metadata as any) || {}; + const artistName = metadata?.artistName?.toLowerCase().trim() || ""; + const albumTitle = metadata?.albumTitle?.toLowerCase().trim() || ""; + + // FIRST: Check if a COMPLETED job already exists for this album + // This handles the case where a duplicate job succeeded while this one was processing + if (artistName && albumTitle) { + const completedDuplicate = await prisma.downloadJob.findFirst({ + where: { + id: { not: job.id }, + status: "completed", + }, + }); + + if (completedDuplicate) { + const dupMeta = completedDuplicate.metadata as any; + const dupArtist = dupMeta?.artistName?.toLowerCase().trim() || ""; + const dupAlbum = dupMeta?.albumTitle?.toLowerCase().trim() || ""; + + if (dupArtist === artistName && dupAlbum === albumTitle) { + console.log(` Found completed duplicate - marking this job as completed too`); + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "completed", + completedAt: new Date(), + error: null, + metadata: { + ...metadata, + mergedWithJob: completedDuplicate.id, + }, + }, + }); + continue; // Skip to next stale job + } + } + } + + // Clean up from Lidarr queue if possible + const lidarrAlbumId = (job as any).lidarrAlbumId; + if (lidarrAlbumId && job.lidarrRef) { + await this.blocklistAndRetry(job.lidarrRef, lidarrAlbumId); + } + + // Use same-artist fallback ONLY for non-discovery jobs + // Discovery jobs should find NEW artists via the discovery system + let replacementStarted = false; + const artistMbid = job.artistMbid || metadata.artistMbid; + + if (artistMbid && !job.discoveryBatchId) { + console.log(` Attempting same-artist fallback...`); + try { + const fallbackResult = await this.tryNextAlbumFromArtist( + { ...job, metadata }, + errorMessage + ); + if (fallbackResult.retried && fallbackResult.jobId) { + console.log(` Same-artist fallback started: ${fallbackResult.jobId}`); + replacementStarted = true; + } + } catch (fallbackErr: any) { + console.error(` Same-artist fallback error: ${fallbackErr.message}`); + } + } else if (job.discoveryBatchId) { + console.log(` Discovery job - letting discovery system find new artist`); + } + + // If no replacement was started, mark the original job as failed + // NOTE: No notification here - stale cleanup is a background safety net + // Notifications are only sent from markJobExhausted when truly exhausted + if (!replacementStarted) { + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "failed", + error: errorMessage, + completedAt: new Date(), + }, + }); + } + + if (job.discoveryBatchId) { + batchIds.add(job.discoveryBatchId); + } + + // Track download batch IDs for artist downloads + if (metadata?.batchId) { + downloadBatchIds.add(metadata.batchId); + } + } + + // Check discovery batch completion for affected batches + if (batchIds.size > 0) { + const { discoverWeeklyService } = await import("./discoverWeekly"); + for (const batchId of batchIds) { + console.log( + ` Checking discovery batch completion: ${batchId}` + ); + await discoverWeeklyService.checkBatchCompletion(batchId); + } + } + + return staleJobs.length; + } + + /** + * Blocklist a failed release and let Lidarr search for alternatives + * skipRedownload=false tells Lidarr to automatically search for another release + */ + private async blocklistAndRetry(downloadId: string, _lidarrAlbumId: number) { + try { + const settings = await getSystemSettings(); + if (!settings?.lidarrUrl || !settings?.lidarrApiKey) return; + + // Get queue to find the specific release + try { + const queueResponse = await axios.get( + `${settings.lidarrUrl}/api/v1/queue`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + + const queueItem = queueResponse.data.records?.find( + (item: any) => item.downloadId === downloadId + ); + + if (queueItem) { + // Remove from queue with blocklist=true and skipRedownload=false + // Lidarr will automatically search for another release + await axios.delete( + `${settings.lidarrUrl}/api/v1/queue/${queueItem.id}?removeFromClient=true&blocklist=true&skipRedownload=false`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + console.log(` Blocklisted release, Lidarr searching for alternative`); + } + } catch (queueError: any) { + // Queue item may have already been removed + console.log(` Queue cleanup: ${queueError.message}`); + } + } catch (error: any) { + console.error(` Blocklist/retry failed:`, error.message); + } + } + + /** + * Remove a failed download from Lidarr's queue (without retrying) + * Used when we don't have a tracking job but still need to clean up + */ + private async removeFromLidarrQueue(downloadId: string) { + try { + const settings = await getSystemSettings(); + if (!settings?.lidarrUrl || !settings?.lidarrApiKey) return; + + const queueResponse = await axios.get( + `${settings.lidarrUrl}/api/v1/queue`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + + const queueItem = queueResponse.data.records?.find( + (item: any) => item.downloadId === downloadId + ); + + if (queueItem) { + // Remove from queue with blocklist=true and skipRedownload=false + // skipRedownload=false tells Lidarr to search for another release + await axios.delete( + `${settings.lidarrUrl}/api/v1/queue/${queueItem.id}?removeFromClient=true&blocklist=true&skipRedownload=false`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + console.log(` Removed from Lidarr queue, blocklisted, triggering new search`); + } else { + console.log( + ` Item not found in Lidarr queue (may already be removed)` + ); + } + } catch (error: any) { + console.error( + ` Failed to remove from Lidarr queue:`, + error.message + ); + } + } + + /** + * Clear all failed/stuck items from Lidarr's download queue + * and trigger new searches for the albums + */ + async clearLidarrQueue(): Promise<{ removed: number; errors: string[] }> { + const errors: string[] = []; + let removed = 0; + const albumIdsToSearch: number[] = []; + + try { + const settings = await getSystemSettings(); + if (!settings?.lidarrUrl || !settings?.lidarrApiKey) { + return { removed: 0, errors: ["Lidarr not configured"] }; + } + + console.log(`\nClearing Lidarr download queue...`); + + const queueResponse = await axios.get( + `${settings.lidarrUrl}/api/v1/queue`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + + const records = queueResponse.data.records || []; + + if (records.length === 0) { + return { removed: 0, errors: [] }; + } + + console.log(` Found ${records.length} items in queue`); + + // Filter for failed/warning status items + const failedItems = records.filter( + (item: any) => + item.status === "warning" || + item.status === "failed" || + item.trackedDownloadStatus === "warning" || + item.trackedDownloadStatus === "error" || + item.trackedDownloadState === "importPending" || + item.trackedDownloadState === "importFailed" || + (item.statusMessages && item.statusMessages.length > 0) + ); + + if (failedItems.length === 0) { + return { removed: 0, errors: [] }; + } + + console.log(` ${failedItems.length} items have errors/warnings`); + + for (const item of failedItems) { + try { + // Collect album IDs for re-search + if (item.albumId) { + albumIdsToSearch.push(item.albumId); + } + + // Remove from queue with blocklist + await axios.delete( + `${settings.lidarrUrl}/api/v1/queue/${item.id}?removeFromClient=true&blocklist=true&skipRedownload=false`, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + console.log( + ` Removed: ${ + item.title || item.album?.title || "Unknown" + }` + ); + removed++; + } catch (error: any) { + const msg = `Failed to remove ${item.id}: ${error.message}`; + console.log(` ✗ ${msg}`); + errors.push(msg); + } + } + + // Explicitly trigger album searches for removed items + if (albumIdsToSearch.length > 0) { + try { + console.log( + ` Triggering search for ${albumIdsToSearch.length} album(s)...` + ); + await axios.post( + `${settings.lidarrUrl}/api/v1/command`, + { + name: "AlbumSearch", + albumIds: albumIdsToSearch, + }, + { + headers: { "X-Api-Key": settings.lidarrApiKey }, + timeout: 10000, + } + ); + console.log( + ` Search triggered for alternative releases` + ); + } catch (searchError: any) { + console.log( + ` Failed to trigger search: ${searchError.message}` + ); + } + } + + console.log(` Removed ${removed} items from queue`); + return { removed, errors }; + } catch (error: any) { + console.error(` Queue cleanup failed:`, error.message); + return { removed, errors: [error.message] }; + } + } + + /** + * Get statistics about current downloads + */ + async getStats(): Promise<{ + pending: number; + processing: number; + completed: number; + failed: number; + }> { + const [pending, processing, completed, failed] = await Promise.all([ + prisma.downloadJob.count({ where: { status: "pending" } }), + prisma.downloadJob.count({ where: { status: "processing" } }), + prisma.downloadJob.count({ where: { status: "completed" } }), + prisma.downloadJob.count({ where: { status: "failed" } }), + ]); + + return { pending, processing, completed, failed }; + } + + /** + * Reconcile processing jobs with Lidarr + * Checks if albums in "processing" state are already available in Lidarr + * and marks them as completed if so (fixes missed webhook completion events) + * + * IMPORTANT: Checks by both MBID and artist+album name to handle MBID mismatches + */ + async reconcileWithLidarr(): Promise<{ reconciled: number; errors: string[] }> { + console.log(`\n[RECONCILE] Checking processing jobs against Lidarr...`); + + const processingJobs = await prisma.downloadJob.findMany({ + where: { status: "processing" }, + }); + + if (processingJobs.length === 0) { + console.log(` No processing jobs to reconcile`); + return { reconciled: 0, errors: [] }; + } + + console.log(` Found ${processingJobs.length} processing job(s)`); + + let reconciled = 0; + const errors: string[] = []; + + for (const job of processingJobs) { + const metadata = job.metadata as any; + const albumMbid = job.targetMbid || metadata?.albumMbid || metadata?.lidarrMbid; + const artistName = metadata?.artistName; + const albumTitle = metadata?.albumTitle; + + try { + let isAvailable = false; + + // Strategy 1: Check by MBID(s) + if (albumMbid) { + isAvailable = await lidarrService.isAlbumAvailable(albumMbid); + + // Also try lidarrMbid if different + if (!isAvailable && metadata?.lidarrMbid && metadata.lidarrMbid !== albumMbid) { + isAvailable = await lidarrService.isAlbumAvailable(metadata.lidarrMbid); + } + } + + // Strategy 2: Check by artist+album name (handles MBID mismatches) + if (!isAvailable && artistName && albumTitle) { + isAvailable = await lidarrService.isAlbumAvailableByTitle(artistName, albumTitle); + } + + // Strategy 3: Parse subject if no metadata (format: "Artist - Album") + if (!isAvailable && !artistName && job.subject) { + const parts = job.subject.split(" - "); + if (parts.length >= 2) { + const parsedArtist = parts[0].trim(); + const parsedAlbum = parts.slice(1).join(" - ").trim(); + isAvailable = await lidarrService.isAlbumAvailableByTitle(parsedArtist, parsedAlbum); + } + } + + if (isAvailable) { + console.log(` Job ${job.id}: Album "${job.subject}" found in Lidarr - marking complete`); + + await prisma.downloadJob.update({ + where: { id: job.id }, + data: { + status: "completed", + completedAt: new Date(), + error: null, + metadata: { + ...metadata, + completedAt: new Date().toISOString(), + reconciledFromLidarr: true, + }, + }, + }); + + // Check batch completion for discovery jobs + if (job.discoveryBatchId) { + const { discoverWeeklyService } = await import("./discoverWeekly"); + await discoverWeeklyService.checkBatchCompletion(job.discoveryBatchId); + } + + reconciled++; + } else { + // Only log for jobs older than 5 minutes + const jobAge = Date.now() - (job.createdAt?.getTime() || 0); + if (jobAge > 5 * 60 * 1000) { + console.log(` Job ${job.id}: "${job.subject}" not yet available in Lidarr (${Math.round(jobAge / 60000)}m old)`); + } + } + } catch (error: any) { + const msg = `Job ${job.id}: Error checking Lidarr - ${error.message}`; + console.error(` ${msg}`); + errors.push(msg); + } + } + + console.log(`[RECONCILE] Reconciled ${reconciled} job(s)`); + return { reconciled, errors }; + } +} + +// Singleton instance +export const simpleDownloadManager = new SimpleDownloadManager(); diff --git a/backend/src/services/soulseek.ts b/backend/src/services/soulseek.ts new file mode 100644 index 0000000..caeec14 --- /dev/null +++ b/backend/src/services/soulseek.ts @@ -0,0 +1,1008 @@ +/** + * Direct Soulseek integration using slsk-client + * Replaces the SLSKD Docker container with native Node.js connection + */ + +import slsk from "slsk-client"; +import path from "path"; +import fs from "fs"; +import PQueue from "p-queue"; +import { getSystemSettings } from "../utils/systemSettings"; +import { sessionLog } from "../utils/playlistLogger"; + +// slsk-client types +interface SlskClient { + search( + opts: { req: string; timeout: number }, + cb: (err: Error | null, results: SearchResult[]) => void + ): void; + download( + opts: { file: SearchResult; path: string }, + cb: (err: Error | null, data?: { buffer: Buffer }) => void + ): void; +} + +export interface SearchResult { + user: string; + file: string; + size: number; + slots: boolean; + bitrate?: number; + speed: number; +} + +export interface TrackMatch { + username: string; + filename: string; + fullPath: string; + size: number; + bitRate?: number; + quality: string; + score: number; +} + +export interface SearchTrackResult { + found: boolean; + bestMatch: TrackMatch | null; + allMatches: TrackMatch[]; // All ranked matches for retry +} + +class SoulseekService { + private client: SlskClient | null = null; + private connecting = false; + private connectPromise: Promise | null = null; + private lastConnectAttempt = 0; + private readonly RECONNECT_COOLDOWN = 30000; // 30 seconds between reconnect attempts + private readonly DOWNLOAD_TIMEOUT = 180000; // 3 minutes per download attempt + private readonly MAX_DOWNLOAD_RETRIES = 3; // Try up to 3 different users + + // Connection health tracking + private connectedAt: Date | null = null; + private lastSuccessfulSearch: Date | null = null; + private consecutiveEmptySearches = 0; + private totalSearches = 0; + private totalSuccessfulSearches = 0; + private readonly MAX_CONSECUTIVE_EMPTY = 3; // After 3 empty searches, force reconnect + + /** + * Normalize track title for better search results + * Extracts main song name by removing live performance details, remasters, etc. + * e.g. "Santa Claus Is Comin' to Town (Live at C.W. Post College, NY - Dec 1975)" → "Santa Claus Is Comin' to Town" + */ + private normalizeTrackTitle(title: string): string { + // First, normalize Unicode characters to ASCII equivalents for better search matching + let normalized = title + .replace(/…/g, "") // Remove ellipsis (U+2026) - files don't have this + .replace(/[''′`]/g, "'") // Smart apostrophes → ASCII apostrophe + .replace(/[""]/g, '"') // Smart quotes → ASCII quotes + .replace(/\//g, " ") // Slash → space (file names can't have /) + .replace(/[–—]/g, "-") // En/em dash → hyphen + .replace(/[×]/g, "x"); // Multiplication sign → x + + // Remove content in parentheses that contains live/remaster/remix info + const livePatterns = + /\s*\([^)]*(?:live|remaster|remix|version|edit|demo|acoustic|radio|single|extended|instrumental|feat\.|ft\.|featuring)[^)]*\)\s*/gi; + normalized = normalized.replace(livePatterns, " "); + + // Also try brackets + const bracketPatterns = + /\s*\[[^\]]*(?:live|remaster|remix|version|edit|demo|acoustic|radio|single|extended|instrumental|feat\.|ft\.|featuring)[^\]]*\]\s*/gi; + normalized = normalized.replace(bracketPatterns, " "); + + // Remove trailing dash content (often contains year or version info) + normalized = normalized.replace( + /\s*-\s*(\d{4}|remaster|live|remix|version|edit|demo|acoustic).*$/i, + "" + ); + + // Clean up whitespace + normalized = normalized.replace(/\s+/g, " ").trim(); + + // If we stripped too much, return original + if (normalized.length < 3) { + return title; + } + + return normalized; + } + + /** + * Connect to Soulseek network + */ + async connect(): Promise { + const settings = await getSystemSettings(); + + if (!settings?.soulseekUsername || !settings?.soulseekPassword) { + throw new Error("Soulseek credentials not configured"); + } + + sessionLog("SOULSEEK", `Connecting as ${settings.soulseekUsername}...`); + + return new Promise((resolve, reject) => { + slsk.connect( + { + user: settings.soulseekUsername, + pass: settings.soulseekPassword, + }, + (err: Error | null, client: SlskClient) => { + if (err) { + sessionLog( + "SOULSEEK", + `Connection failed: ${err.message}`, + "ERROR" + ); + return reject(err); + } + this.client = client; + this.connectedAt = new Date(); + this.consecutiveEmptySearches = 0; + sessionLog("SOULSEEK", "Connected to Soulseek network"); + resolve(); + } + ); + }); + } + + /** + * Force disconnect and clear client state + */ + private forceDisconnect(): void { + const uptime = this.connectedAt + ? Math.round((Date.now() - this.connectedAt.getTime()) / 1000) + : 0; + sessionLog( + "SOULSEEK", + `Force disconnecting (was connected for ${uptime}s)`, + "WARN" + ); + this.client = null; + this.connectedAt = null; + this.lastConnectAttempt = 0; // Allow immediate reconnect + } + + /** + * Ensure we have an active connection + * @param force - If true, disconnect and reconnect even if client exists + */ + private async ensureConnected(force: boolean = false): Promise { + if (force && this.client) { + this.forceDisconnect(); + } + + if (this.client) { + return; + } + + // Prevent multiple simultaneous connection attempts + if (this.connecting && this.connectPromise) { + return this.connectPromise; + } + + // Cooldown between reconnect attempts (skip if forced) + const now = Date.now(); + if (!force && now - this.lastConnectAttempt < this.RECONNECT_COOLDOWN) { + throw new Error( + "Connection cooldown - please wait before retrying" + ); + } + + this.connecting = true; + this.lastConnectAttempt = now; + + this.connectPromise = this.connect().finally(() => { + this.connecting = false; + this.connectPromise = null; + }); + + return this.connectPromise; + } + + /** + * Check if connected to Soulseek + */ + isConnected(): boolean { + return this.client !== null; + } + + /** + * Check if Soulseek is available (credentials configured) + */ + async isAvailable(): Promise { + try { + const settings = await getSystemSettings(); + return !!(settings?.soulseekUsername && settings?.soulseekPassword); + } catch { + return false; + } + } + + /** + * Get connection status + */ + async getStatus(): Promise<{ + connected: boolean; + username: string | null; + }> { + const settings = await getSystemSettings(); + return { + connected: this.client !== null, + username: settings?.soulseekUsername || null, + }; + } + + /** + * Search for a track and return the best match plus alternatives for retry + */ + async searchTrack( + artistName: string, + trackTitle: string, + isRetry: boolean = false + ): Promise { + this.totalSearches++; + const searchId = this.totalSearches; + const connectionAge = this.connectedAt + ? Math.round((Date.now() - this.connectedAt.getTime()) / 1000) + : 0; + + try { + await this.ensureConnected(); + } catch (err: any) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Connection error: ${err.message}`, + "ERROR" + ); + return { found: false, bestMatch: null, allMatches: [] }; + } + + if (!this.client) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Client not connected`, + "ERROR" + ); + return { found: false, bestMatch: null, allMatches: [] }; + } + + // Normalize title to extract main song name (removes live/remaster info) + const normalizedTitle = this.normalizeTrackTitle(trackTitle); + const useNormalized = normalizedTitle !== trackTitle; + + const query = `${artistName} ${normalizedTitle}`; + if (useNormalized) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Normalized: "${trackTitle}" → "${normalizedTitle}"` + ); + } + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Searching: "${query}" (connected ${connectionAge}s, ${this.consecutiveEmptySearches} consecutive empty)` + ); + + return new Promise(async (resolve) => { + const searchStartTime = Date.now(); + this.client!.search( + { + req: query, + timeout: 45000, // 45 seconds for individual tracks (popular tracks can return large results) + }, + async (err, results) => { + const searchDuration = Date.now() - searchStartTime; + + if (err) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Search error after ${searchDuration}ms: ${err.message}`, + "ERROR" + ); + this.consecutiveEmptySearches++; + + // If we get an error and haven't retried, force reconnect and try again + if (!isRetry && this.consecutiveEmptySearches >= 2) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Search error detected, forcing reconnect and retry...`, + "WARN" + ); + this.forceDisconnect(); + return resolve( + await this.searchTrack( + artistName, + trackTitle, + true + ) + ); + } + + return resolve({ + found: false, + bestMatch: null, + allMatches: [], + }); + } + + if (!results || results.length === 0) { + this.consecutiveEmptySearches++; + sessionLog( + "SOULSEEK", + `[Search #${searchId}] No results found after ${searchDuration}ms (${this.consecutiveEmptySearches}/${this.MAX_CONSECUTIVE_EMPTY} consecutive empty)`, + "WARN" + ); + + // If too many consecutive empty searches, connection might be stale + if ( + !isRetry && + this.consecutiveEmptySearches >= + this.MAX_CONSECUTIVE_EMPTY + ) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Too many consecutive empty searches, forcing reconnect and retry...`, + "WARN" + ); + this.forceDisconnect(); + return resolve( + await this.searchTrack( + artistName, + trackTitle, + true + ) + ); + } + + return resolve({ + found: false, + bestMatch: null, + allMatches: [], + }); + } + + // Reset consecutive empty counter on successful results + this.consecutiveEmptySearches = 0; + this.lastSuccessfulSearch = new Date(); + this.totalSuccessfulSearches++; + + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Found ${ + results.length + } results in ${searchDuration}ms (success rate: ${Math.round( + (this.totalSuccessfulSearches / + this.totalSearches) * + 100 + )}%)` + ); + + // Filter for audio files with available slots + const audioExtensions = [ + ".flac", + ".mp3", + ".m4a", + ".ogg", + ".opus", + ".wav", + ".aac", + ]; + const audioFiles = results.filter((r) => { + const filename = (r.file || "").toLowerCase(); + const isAudio = audioExtensions.some((ext) => + filename.endsWith(ext) + ); + // Prefer files with slots available (faster download) + return isAudio; + }); + + if (audioFiles.length === 0) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] No audio files in ${results.length} results`, + "WARN" + ); + return resolve({ + found: false, + bestMatch: null, + allMatches: [], + }); + } + + // Rank and score all results + const rankedMatches = this.rankAllResults( + audioFiles, + artistName, + trackTitle + ); + + if (rankedMatches.length === 0) { + sessionLog( + "SOULSEEK", + `[Search #${searchId}] No suitable match found from ${audioFiles.length} audio files`, + "WARN" + ); + return resolve({ + found: false, + bestMatch: null, + allMatches: [], + }); + } + + const best = rankedMatches[0]; + sessionLog( + "SOULSEEK", + `[Search #${searchId}] ✓ MATCH: ${best.filename} | ${ + best.quality + } | ${Math.round(best.size / 1024 / 1024)}MB | User: ${ + best.username + } | Score: ${best.score}` + ); + sessionLog( + "SOULSEEK", + `[Search #${searchId}] Found ${rankedMatches.length} alternative sources for retry` + ); + + resolve({ + found: true, + bestMatch: best, + allMatches: rankedMatches, + }); + } + ); + }); + } + + /** + * Rank all search results and return sorted matches (best first) + * Filters out matches below minimum score threshold + */ + private rankAllResults( + results: SearchResult[], + artistName: string, + trackTitle: string + ): TrackMatch[] { + // Normalize search terms for matching + const normalizedArtist = artistName + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ""); + const normalizedTitle = trackTitle + .toLowerCase() + .replace(/[^a-z0-9\s]/g, "") + .replace(/^\d+\s*[-.]?\s*/, ""); // Remove leading track numbers + + // Get first word of artist for fuzzy matching + const artistFirstWord = normalizedArtist.split(/\s+/)[0]; + // Get first few significant words of title + const titleWords = normalizedTitle + .split(/\s+/) + .filter((w) => w.length > 2) + .slice(0, 3); + + const scored = results.map((file) => { + const filename = (file.file || "").toLowerCase(); + const normalizedFilename = filename.replace(/[^a-z0-9]/g, ""); + const shortFilename = filename.split(/[/\\]/).pop() || filename; + + let score = 0; + + // Prefer files with slots available (+20) + if (file.slots) score += 20; + + // Check if filename contains artist (full or first word) + if ( + normalizedFilename.includes(normalizedArtist.replace(/\s/g, "")) + ) { + score += 50; // Full artist match + } else if ( + artistFirstWord.length >= 3 && + normalizedFilename.includes(artistFirstWord) + ) { + score += 35; // Partial artist match (first word) + } + + // Check if filename contains title (full or partial) + const titleNoSpaces = normalizedTitle.replace(/\s/g, ""); + if (normalizedFilename.includes(titleNoSpaces)) { + score += 50; // Full title match + } else if ( + titleWords.length > 0 && + titleWords.every((w) => normalizedFilename.includes(w)) + ) { + score += 40; // All significant title words match + } else if ( + titleWords.length > 0 && + titleWords.some( + (w) => w.length > 4 && normalizedFilename.includes(w) + ) + ) { + score += 25; // At least one significant title word matches + } + + // Prefer FLAC (+30) + if (filename.endsWith(".flac")) score += 30; + // Then high-quality MP3 (+20 for 320, +10 for 256) + else if (filename.endsWith(".mp3") && (file.bitrate || 0) >= 320) + score += 20; + else if (filename.endsWith(".mp3") && (file.bitrate || 0) >= 256) + score += 10; + + // Prefer reasonable file sizes + const sizeMB = (file.size || 0) / 1024 / 1024; + if (sizeMB >= 3 && sizeMB <= 100) score += 10; + if (sizeMB >= 10 && sizeMB <= 50) score += 5; // FLAC range + + // Prefer higher speed peers + if (file.speed > 1000000) score += 5; // >1MB/s + + const quality = this.getQualityFromFilename( + file.file, + file.bitrate + ); + + return { + username: file.user, + filename: shortFilename, + fullPath: file.file, + size: file.size, + bitRate: file.bitrate, + quality, + score, + }; + }); + + // Sort by score descending, filter by minimum threshold + // Score 20+ is acceptable: slots(20) OR artist match(35-50) OR title match(25-50) + return scored + .filter((m) => m.score >= 20) + .sort((a, b) => b.score - a.score) + .slice(0, 10); // Keep top 10 for retry purposes + } + + /** + * Download a track directly to the music library with timeout + */ + async downloadTrack( + match: TrackMatch, + destPath: string + ): Promise<{ success: boolean; error?: string }> { + try { + await this.ensureConnected(); + } catch (err: any) { + return { success: false, error: err.message }; + } + + if (!this.client) { + return { success: false, error: "Not connected" }; + } + + // Ensure destination directory exists + const destDir = path.dirname(destPath); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }); + } + + sessionLog( + "SOULSEEK", + `Downloading from ${match.username}: ${match.filename} -> ${destPath}` + ); + + return new Promise((resolve) => { + let resolved = false; + + // Timeout handler - 3 minutes max per download attempt + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + sessionLog( + "SOULSEEK", + `Download timed out after ${ + this.DOWNLOAD_TIMEOUT / 1000 + }s: ${match.filename}`, + "WARN" + ); + // Clean up partial file if it exists + if (fs.existsSync(destPath)) { + try { + fs.unlinkSync(destPath); + } catch (e) { + // Ignore cleanup errors + } + } + resolve({ success: false, error: "Download timed out" }); + } + }, this.DOWNLOAD_TIMEOUT); + + // Create a SearchResult object for the download + const downloadFile: SearchResult = { + user: match.username, + file: match.fullPath, + size: match.size, + slots: true, + bitrate: match.bitRate, + speed: 0, + }; + + this.client!.download( + { + file: downloadFile, + path: destPath, + }, + (err) => { + if (resolved) return; // Already timed out + resolved = true; + clearTimeout(timeoutId); + + if (err) { + sessionLog( + "SOULSEEK", + `Download failed: ${err.message}`, + "ERROR" + ); + return resolve({ success: false, error: err.message }); + } + + // Verify file was written + if (fs.existsSync(destPath)) { + const stats = fs.statSync(destPath); + sessionLog( + "SOULSEEK", + `✓ Downloaded: ${match.filename} (${Math.round( + stats.size / 1024 + )}KB)` + ); + resolve({ success: true }); + } else { + sessionLog( + "SOULSEEK", + "File not found after download", + "ERROR" + ); + resolve({ success: false, error: "File not written" }); + } + } + ); + }); + } + + /** + * Search and download a track in one operation + * Includes retry logic - tries multiple users if first fails/times out + */ + async searchAndDownload( + artistName: string, + trackTitle: string, + albumName: string, + musicPath: string + ): Promise<{ success: boolean; filePath?: string; error?: string }> { + // Search for the track + const searchResult = await this.searchTrack(artistName, trackTitle); + + if (!searchResult.found || searchResult.allMatches.length === 0) { + return { success: false, error: "No suitable match found" }; + } + + const sanitize = (name: string) => + name.replace(/[<>:"/\\|?*]/g, "_").trim(); + const errors: string[] = []; + + // Try up to MAX_DOWNLOAD_RETRIES different users + const matchesToTry = searchResult.allMatches.slice( + 0, + this.MAX_DOWNLOAD_RETRIES + ); + + for (let attempt = 0; attempt < matchesToTry.length; attempt++) { + const match = matchesToTry[attempt]; + + sessionLog( + "SOULSEEK", + `Attempt ${attempt + 1}/${matchesToTry.length}: Trying ${ + match.username + } for ${match.filename}` + ); + + // Build destination path: Singles/Artist/Album/filename + const destPath = path.join( + musicPath, + "Singles", + sanitize(artistName), + sanitize(albumName), + sanitize(match.filename) + ); + + // Download with timeout + const downloadResult = await this.downloadTrack(match, destPath); + + if (downloadResult.success) { + if (attempt > 0) { + sessionLog( + "SOULSEEK", + `✓ Success on attempt ${attempt + 1} (user: ${ + match.username + })` + ); + } + return { success: true, filePath: destPath }; + } + + // Log failure and try next user + const errorMsg = downloadResult.error || "Unknown error"; + errors.push(`${match.username}: ${errorMsg}`); + sessionLog( + "SOULSEEK", + `Attempt ${ + attempt + 1 + } failed: ${errorMsg}, trying next user...`, + "WARN" + ); + } + + // All attempts failed + sessionLog( + "SOULSEEK", + `All ${matchesToTry.length} download attempts failed for: ${artistName} - ${trackTitle}`, + "ERROR" + ); + return { + success: false, + error: `All ${matchesToTry.length} attempts failed: ${errors.join( + "; " + )}`, + }; + } + + /** + * Download best match from pre-searched results + * Used when search was already done separately (e.g., for retry functionality) + */ + async downloadBestMatch( + artistName: string, + trackTitle: string, + albumName: string, + allMatches: TrackMatch[], + musicPath: string + ): Promise<{ success: boolean; filePath?: string; error?: string }> { + if (allMatches.length === 0) { + return { success: false, error: "No matches provided" }; + } + + const sanitize = (name: string) => + name.replace(/[<>:"/\\|?*]/g, "_").trim(); + const errors: string[] = []; + + // Try up to MAX_DOWNLOAD_RETRIES different users + const matchesToTry = allMatches.slice(0, this.MAX_DOWNLOAD_RETRIES); + + for (let attempt = 0; attempt < matchesToTry.length; attempt++) { + const match = matchesToTry[attempt]; + + sessionLog( + "SOULSEEK", + `[${artistName} - ${trackTitle}] Attempt ${attempt + 1}/${ + matchesToTry.length + }: Trying ${match.username}` + ); + + // Build destination path: Singles/Artist/Album/filename + const destPath = path.join( + musicPath, + "Singles", + sanitize(artistName), + sanitize(albumName), + sanitize(match.filename) + ); + + // Download with timeout + const downloadResult = await this.downloadTrack(match, destPath); + + if (downloadResult.success) { + if (attempt > 0) { + sessionLog( + "SOULSEEK", + `✓ Success on attempt ${attempt + 1} (user: ${ + match.username + })` + ); + } + return { success: true, filePath: destPath }; + } + + // Log failure and try next user + const errorMsg = downloadResult.error || "Unknown error"; + errors.push(`${match.username}: ${errorMsg}`); + sessionLog( + "SOULSEEK", + `Attempt ${attempt + 1} failed: ${errorMsg}`, + "WARN" + ); + } + + // All attempts failed + return { + success: false, + error: `All ${matchesToTry.length} attempts failed: ${errors.join( + "; " + )}`, + }; + } + + /** + * Search and download multiple tracks in parallel + * - Searches run fully parallel (fast, 15s timeout each) + * - Downloads limited to concurrency of 4 to prevent network saturation + */ + async searchAndDownloadBatch( + tracks: Array<{ artist: string; title: string; album: string }>, + musicPath: string, + concurrency: number = 4 + ): Promise<{ + successful: number; + failed: number; + files: string[]; + errors: string[]; + }> { + const downloadQueue = new PQueue({ concurrency }); + const results: { + successful: number; + failed: number; + files: string[]; + errors: string[]; + } = { + successful: 0, + failed: 0, + files: [], + errors: [], + }; + + // Phase 1: Search all tracks in parallel (searches are fast) + sessionLog( + "SOULSEEK", + `Searching for ${tracks.length} tracks in parallel...` + ); + const searchPromises = tracks.map((track) => + this.searchTrack(track.artist, track.title).then((result) => ({ + track, + result, + })) + ); + const searchResults = await Promise.all(searchPromises); + + // Phase 2: Queue downloads with concurrency limit + const tracksWithMatches = searchResults.filter( + (r) => r.result.found && r.result.allMatches.length > 0 + ); + sessionLog( + "SOULSEEK", + `Found matches for ${tracksWithMatches.length}/${tracks.length} tracks, downloading with concurrency ${concurrency}...` + ); + + // Count tracks with no search results as failed + const noMatchTracks = searchResults.filter( + (r) => !r.result.found || r.result.allMatches.length === 0 + ); + for (const { track } of noMatchTracks) { + results.failed++; + results.errors.push( + `${track.artist} - ${track.title}: No match found on Soulseek` + ); + } + + // Queue downloads for tracks with matches + const downloadPromises = tracksWithMatches.map(({ track, result }) => + downloadQueue.add(async () => { + const downloadResult = await this.downloadWithRetry( + track.artist, + track.title, + track.album, + result.allMatches, + musicPath + ); + if (downloadResult.success && downloadResult.filePath) { + results.successful++; + results.files.push(downloadResult.filePath); + } else { + results.failed++; + results.errors.push( + `${track.artist} - ${track.title}: ${ + downloadResult.error || "Unknown error" + }` + ); + } + }) + ); + + await Promise.all(downloadPromises); + + sessionLog( + "SOULSEEK", + `Batch complete: ${results.successful} succeeded, ${results.failed} failed` + ); + + return results; + } + + /** + * Download with retry logic (extracted for use by batch downloads) + */ + private async downloadWithRetry( + artistName: string, + trackTitle: string, + albumName: string, + allMatches: TrackMatch[], + musicPath: string + ): Promise<{ success: boolean; filePath?: string; error?: string }> { + const sanitize = (name: string) => + name.replace(/[<>:"/\\|?*]/g, "_").trim(); + const errors: string[] = []; + const matchesToTry = allMatches.slice(0, this.MAX_DOWNLOAD_RETRIES); + + for (let attempt = 0; attempt < matchesToTry.length; attempt++) { + const match = matchesToTry[attempt]; + + sessionLog( + "SOULSEEK", + `[${artistName} - ${trackTitle}] Attempt ${attempt + 1}/${ + matchesToTry.length + }: Trying ${match.username}` + ); + + const destPath = path.join( + musicPath, + "Singles", + sanitize(artistName), + sanitize(albumName), + sanitize(match.filename) + ); + + const result = await this.downloadTrack(match, destPath); + if (result.success) { + if (attempt > 0) { + sessionLog( + "SOULSEEK", + `[${artistName} - ${trackTitle}] ✓ Success on attempt ${ + attempt + 1 + }` + ); + } + return { success: true, filePath: destPath }; + } + errors.push(`${match.username}: ${result.error}`); + } + + sessionLog( + "SOULSEEK", + `[${artistName} - ${trackTitle}] All ${matchesToTry.length} attempts failed`, + "ERROR" + ); + return { success: false, error: errors.join("; ") }; + } + + /** + * Get quality string from filename/bitrate + */ + private getQualityFromFilename(filename: string, bitRate?: number): string { + const lowerFilename = filename.toLowerCase(); + if (lowerFilename.endsWith(".flac")) return "FLAC"; + if (lowerFilename.endsWith(".wav")) return "WAV"; + if (lowerFilename.endsWith(".mp3")) { + if (bitRate && bitRate >= 320) return "MP3 320"; + if (bitRate && bitRate >= 256) return "MP3 256"; + if (bitRate && bitRate >= 192) return "MP3 192"; + return "MP3"; + } + if (lowerFilename.endsWith(".m4a") || lowerFilename.endsWith(".aac")) + return "AAC"; + if (lowerFilename.endsWith(".ogg")) return "OGG"; + if (lowerFilename.endsWith(".opus")) return "OPUS"; + return "Unknown"; + } + + /** + * Disconnect from Soulseek + */ + disconnect(): void { + this.client = null; + sessionLog("SOULSEEK", "Disconnected"); + } +} + +// Export singleton instance +export const soulseekService = new SoulseekService(); diff --git a/backend/src/services/spotify.ts b/backend/src/services/spotify.ts new file mode 100644 index 0000000..db47146 --- /dev/null +++ b/backend/src/services/spotify.ts @@ -0,0 +1,575 @@ +import axios from "axios"; + +/** + * Spotify Service + * + * Fetches public playlist data from Spotify using anonymous tokens. + * No API credentials required - uses Spotify's web player token endpoint. + */ + +export interface SpotifyTrack { + spotifyId: string; + title: string; + artist: string; + artistId: string; + album: string; + albumId: string; + isrc: string | null; + durationMs: number; + trackNumber: number; + previewUrl: string | null; + coverUrl: string | null; +} + +export interface SpotifyPlaylist { + id: string; + name: string; + description: string | null; + owner: string; + imageUrl: string | null; + trackCount: number; + tracks: SpotifyTrack[]; + isPublic: boolean; +} + +export interface SpotifyAlbum { + id: string; + name: string; + artist: string; + artistId: string; + imageUrl: string | null; + releaseDate: string | null; + trackCount: number; +} + +export interface SpotifyPlaylistPreview { + id: string; + name: string; + description: string | null; + owner: string; + imageUrl: string | null; + trackCount: number; +} + +// URL patterns +const SPOTIFY_PLAYLIST_REGEX = /(?:spotify\.com\/playlist\/|spotify:playlist:)([a-zA-Z0-9]+)/; +const SPOTIFY_ALBUM_REGEX = /(?:spotify\.com\/album\/|spotify:album:)([a-zA-Z0-9]+)/; +const SPOTIFY_TRACK_REGEX = /(?:spotify\.com\/track\/|spotify:track:)([a-zA-Z0-9]+)/; + +class SpotifyService { + private anonymousToken: string | null = null; + private tokenExpiry: number = 0; + + /** + * Get anonymous access token from Spotify web player + * Try multiple endpoints for reliability + */ + private async getAnonymousToken(): Promise { + // Check if we have a valid token + if (this.anonymousToken && Date.now() < this.tokenExpiry - 60000) { + return this.anonymousToken; + } + + // Try multiple endpoints + const endpoints = [ + { + url: "https://open.spotify.com/get_access_token", + params: { reason: "transport", productType: "web_player" } + }, + { + url: "https://open.spotify.com/get_access_token", + params: { reason: "init", productType: "embed" } + } + ]; + + for (const endpoint of endpoints) { + try { + console.log(`Spotify: Fetching anonymous token from ${endpoint.url}...`); + + const response = await axios.get(endpoint.url, { + params: endpoint.params, + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Origin": "https://open.spotify.com", + "Referer": "https://open.spotify.com/", + }, + timeout: 10000, + }); + + const token = response.data?.accessToken; + if (token) { + this.anonymousToken = token; + // Anonymous tokens last about an hour + this.tokenExpiry = Date.now() + 3600 * 1000; + + console.log("Spotify: Got anonymous token"); + return token; + } + } catch (error: any) { + console.log(`Spotify: Token endpoint failed (${error.response?.status || error.message})`); + } + } + + console.error("Spotify: All token endpoints failed - API browsing unavailable"); + return null; + } + + /** + * Parse a Spotify URL and extract the type and ID + */ + parseUrl(url: string): { type: "playlist" | "album" | "track"; id: string } | null { + const playlistMatch = url.match(SPOTIFY_PLAYLIST_REGEX); + if (playlistMatch) { + return { type: "playlist", id: playlistMatch[1] }; + } + + const albumMatch = url.match(SPOTIFY_ALBUM_REGEX); + if (albumMatch) { + return { type: "album", id: albumMatch[1] }; + } + + const trackMatch = url.match(SPOTIFY_TRACK_REGEX); + if (trackMatch) { + return { type: "track", id: trackMatch[1] }; + } + + return null; + } + + /** + * Fetch playlist via anonymous token + */ + private async fetchPlaylistViaAnonymousApi(playlistId: string): Promise { + const token = await this.getAnonymousToken(); + if (!token) { + return await this.fetchPlaylistViaEmbedHtml(playlistId); + } + + try { + console.log(`Spotify: Fetching playlist ${playlistId}...`); + + const playlistResponse = await axios.get( + `https://api.spotify.com/v1/playlists/${playlistId}`, + { + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + params: { + fields: "id,name,description,owner.display_name,images,public,tracks.total,tracks.items(track(id,name,artists(id,name),album(id,name,images),duration_ms,track_number,preview_url,external_ids))", + }, + timeout: 15000, + } + ); + + const playlist = playlistResponse.data; + console.log(`Spotify: Fetched playlist "${playlist.name}" with ${playlist.tracks?.items?.length || 0} tracks`); + + const tracks: SpotifyTrack[] = []; + + for (const item of playlist.tracks?.items || []) { + const track = item.track; + if (!track || !track.id) { + continue; + } + + // Get album name, handling null, undefined, and empty strings + const albumName = track.album?.name?.trim() || "Unknown Album"; + + // Debug log for tracks with Unknown Album + if (albumName === "Unknown Album") { + console.log(`Spotify: Track "${track.name}" has no album data:`, JSON.stringify({ + trackId: track.id, + album: track.album, + hasAlbum: !!track.album, + albumName: track.album?.name, + })); + } + + tracks.push({ + spotifyId: track.id, + title: track.name, + artist: track.artists?.[0]?.name || "Unknown Artist", + artistId: track.artists?.[0]?.id || "", + album: albumName, + albumId: track.album?.id || "", + isrc: track.external_ids?.isrc || null, + durationMs: track.duration_ms || 0, + trackNumber: track.track_number || 0, + previewUrl: track.preview_url || null, + coverUrl: track.album?.images?.[0]?.url || null, + }); + } + + console.log(`Spotify: Processed ${tracks.length} tracks`); + + return { + id: playlist.id, + name: playlist.name, + description: playlist.description, + owner: playlist.owner?.display_name || "Unknown", + imageUrl: playlist.images?.[0]?.url || null, + trackCount: playlist.tracks?.total || tracks.length, + tracks, + isPublic: playlist.public ?? true, + }; + } catch (error: any) { + console.error("Spotify API error:", error.response?.status, error.response?.data || error.message); + + // Fallback to embed HTML parsing + return await this.fetchPlaylistViaEmbedHtml(playlistId); + } + } + + /** + * Last resort: Parse embed HTML for track data + */ + private async fetchPlaylistViaEmbedHtml(playlistId: string): Promise { + try { + console.log("Spotify: Trying embed HTML parsing..."); + + const response = await axios.get( + `https://open.spotify.com/embed/playlist/${playlistId}`, + { + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + timeout: 10000, + } + ); + + const html = response.data; + const match = html.match(/