Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+28
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
github: Chevron7Locked
ko_fi: Chevron7Locked
#custom: ["https://example.com/donate"]
+113
View File
@@ -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
+383
View File
@@ -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
+359
View File
@@ -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"]
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+683
View File
@@ -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)
<p align="center">
<img src="assets/screenshots/desktop-library.png" alt="Library View" width="800">
</p>
### 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
<p align="center">
<img src="assets/screenshots/desktop-podcasts.png" alt="Podcasts" width="800">
</p>
### 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
<p align="center">
<img src="assets/screenshots/desktop-audiobooks.png" alt="Audiobooks" width="800">
</p>
### 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
<p align="center">
<img src="assets/screenshots/vibe-overlay.png" alt="Vibe Overlay" width="800">
</p>
<p align="center">
<img src="assets/screenshots/mood-mixer.png" alt="Mood Mixer" width="800">
</p>
### 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
<p align="center">
<img src="assets/screenshots/deezer-browse.png" alt="Browse Deezer" width="800">
</p>
<p align="center">
<img src="assets/screenshots/spotify-import-preview.png" alt="Import Preview" width="800">
</p>
### 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
<p align="center">
<img src="assets/screenshots/mobile-home.png" alt="Mobile Home" width="280">
<img src="assets/screenshots/mobile-player.png" alt="Mobile Player" width="280">
<img src="assets/screenshots/mobile-library.png" alt="Mobile Library" width="280">
</p>
---
## 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
<p align="center">
<img src="assets/screenshots/desktop-artist.png" alt="Artist Page" width="800">
</p>
<p align="center">
<img src="assets/screenshots/desktop-album.png" alt="Album Page" width="800">
</p>
### 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
<p align="center">
<img src="assets/screenshots/desktop-player.png" alt="Now Playing" width="800">
</p>
<p align="center">
<img src="assets/screenshots/desktop-settings.png" alt="Settings" width="800">
</p>
### 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._
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

+43
View File
@@ -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
+21
View File
@@ -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
+65
View File
@@ -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"]
+59
View File
@@ -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 "$@"
+24
View File
@@ -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();
+5578
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
@@ -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 $$;
@@ -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"
+926
View File
@@ -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])
}
+49
View File
@@ -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<string> {
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();
});
+110
View File
@@ -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 : []),
};
+103
View File
@@ -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);
+300
View File
@@ -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"));
+320
View File
@@ -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<string>();
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();
+205
View File
@@ -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" });
}
+11
View File
@@ -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" });
}
+58
View File
@@ -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,
});
+293
View File
@@ -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;
+231
View File
@@ -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;
+566
View File
@@ -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;
+907
View File
@@ -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;
+532
View File
@@ -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;
+377
View File
@@ -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;
+232
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+588
View File
@@ -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;
+293
View File
@@ -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;
+187
View File
@@ -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<string, number>();
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;
File diff suppressed because it is too large Load Diff
+108
View File
@@ -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;
+684
View File
@@ -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;
+711
View File
@@ -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;
+286
View File
@@ -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<string, number> = {
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;
+475
View File
@@ -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<void> {
// 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;
+149
View File
@@ -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;
+985
View File
@@ -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;
+84
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+469
View File
@@ -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<string, string>();
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;
+259
View File
@@ -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;
+432
View File
@@ -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;
+73
View File
@@ -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;
+193
View File
@@ -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;
+334
View File
@@ -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;
+712
View File
@@ -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<void>((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;
+231
View File
@@ -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<boolean> {
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;
+395
View File
@@ -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<StreamFileInfo> {
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<string | null> {
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<string> {
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<number> {
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<void> {
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<string, string> = {
".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;
}
}
}
+416
View File
@@ -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: <MUSIC_PATH>/cover-cache/audiobooks/
this.coverCacheDir = path.join(
config.music.musicPath,
"cover-cache",
"audiobooks"
);
}
/**
* Sync all audiobooks from Audiobookshelf to our database
*/
async syncAll(): Promise<SyncResult> {
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<void> {
// 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<string | null> {
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<string> {
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<any> {
// 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<number> {
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();
+335
View File
@@ -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<boolean> {
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<string> {
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<string, string> = {};
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();
+67
View File
@@ -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<string | null> {
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();
+75
View File
@@ -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<string | null> {
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<string | null> {
const cacheFileName = `${albumId}.jpg`;
const cachePath = path.join(this.coverCachePath, cacheFileName);
if (fs.existsSync(cachePath)) {
return cacheFileName;
}
return null;
}
}
+390
View File
@@ -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<string | null> {
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<string | null> {
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<string | null> {
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<Map<string, string | null>> {
const results = new Map<string, string | null>();
// 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<Map<string, string | null>> {
const results = new Map<string, string | null>();
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<string | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
+587
View File
@@ -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<string | null> {
try {
return await redisClient.get(`${this.cachePrefix}${key}`);
} catch {
return null;
}
}
/**
* Set cached value in Redis
*/
private async setCache(key: string, value: string): Promise<void> {
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<string | null> {
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<string | null> {
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<string | null> {
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<DeezerPlaylist | null> {
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<DeezerPlaylistPreview[]> {
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<DeezerPlaylistPreview[]> {
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<DeezerPlaylistPreview[]> {
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<string>();
// 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<Array<{ id: number; name: string; imageUrl: string | null }>> {
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<DeezerPlaylistPreview[]> {
return this.searchPlaylists(genreName, limit);
}
// ============================================
// Radio Methods
// ============================================
/**
* Get all radio stations (mood/theme based mixes)
* Cached for 24 hours
*/
async getRadioStations(): Promise<DeezerRadioStation[]> {
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<DeezerGenreWithRadios[]> {
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<DeezerPlaylist | null> {
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();
File diff suppressed because it is too large Load Diff
+226
View File
@@ -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<string, any>, 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();
+658
View File
@@ -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<void>;
class DownloadQueueManager {
private activeDownloads = new Map<string, DownloadInfo>();
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<void> {
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<boolean> {
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();
+664
View File
@@ -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<any>> = [];
private isProcessingQueue = false;
/**
* Get enrichment settings for a user
*/
async getSettings(userId: string): Promise<EnrichmentSettings> {
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<EnrichmentSettings>
): Promise<EnrichmentSettings> {
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<ArtistEnrichmentData | null> {
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<AlbumEnrichmentData | null> {
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<void> {
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<void> {
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<EnrichmentResult> {
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();
+214
View File
@@ -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<string | null> {
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<string | null> {
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();
+175
View File
@@ -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<ValidationResult> {
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<boolean> {
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<boolean> {
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();
+421
View File
@@ -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<ImageResult | null> {
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<ImageResult | null> {
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<ImageResult | null> {
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<ImageResult | null> {
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<ImageResult | null> {
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<ImageResult | null> {
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<ImageResult | null> {
// 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<ImageResult | null> {
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<ImageResult | null> {
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();
+339
View File
@@ -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<T>(
cacheKey: string,
requestFn: () => Promise<T>,
ttlSeconds = 604800 // 7 days default
): Promise<T> {
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<ItunesPodcast[]> {
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<ItunesPodcast | null> {
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<string, number>();
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<ItunesPodcast[]> {
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<ItunesPodcast[]> {
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();
+947
View File
@@ -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<T = any>(params: Record<string, any>) {
await this.ensureInitialized();
const response = await rateLimiter.execute("lastfm", () =>
this.client.get<T>("/", { params })
);
return response.data;
}
async getSimilarArtists(
artistMbid: string,
artistName: string,
limit = 30
): Promise<SimilarArtist[]> {
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<SimilarArtist[]> {
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<string | null>(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();
File diff suppressed because it is too large Load Diff
+766
View File
@@ -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<ScanResult> {
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<boolean> {
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<string[]> {
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<void> {
// 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., <Various Artists>, 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,
},
});
}
}
+656
View File
@@ -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<any>,
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<boolean> {
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<number> {
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();
+225
View File
@@ -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<string, any>;
}
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();

Some files were not shown because too many files have changed in this diff Show More