Initial release v1.0.0
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
github: Chevron7Locked
|
||||
ko_fi: Chevron7Locked
|
||||
#custom: ["https://example.com/donate"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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>.
|
||||
@@ -0,0 +1,683 @@
|
||||
# Lidify
|
||||
|
||||
[](https://hub.docker.com/r/chevron7locked/lidify)
|
||||
[](https://github.com/Chevron7Locked/lidify/releases)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
A self-hosted, on-demand audio streaming platform that brings the Spotify experience to your personal music library.
|
||||
|
||||
Lidify is built for music lovers who want the convenience of streaming services without sacrificing ownership of their library. Point it at your music collection, and Lidify handles the rest: artist discovery, personalized playlists, podcast subscriptions, and seamless integration with tools you already use like Lidarr and Audiobookshelf.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## A Note on Native Apps
|
||||
|
||||
I got a little ambitious trying to ship both a polished web app AND a native Android app at the same time. Turns out, trying to half-ass two things is worse than whole-assing one thing.
|
||||
|
||||
Lidify's web app and PWA are the priority. Once the core experience is solid and properly tested, a native mobile app (likely React Native) is on the roadmap. The PWA works great for most cases for now.
|
||||
|
||||
Thanks for your patience while I work through this.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [The Vibe System](#the-vibe-system)
|
||||
- [Playlist Import](#playlist-import)
|
||||
- [Mobile Support](#mobile-support)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Integrations](#integrations)
|
||||
- [Using Lidify](#using-lidify)
|
||||
- [Administration](#administration)
|
||||
- [Architecture](#architecture)
|
||||
- [Roadmap](#roadmap)
|
||||
- [License](#license)
|
||||
- [Acknowledgments](#acknowledgments)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Your Music, Your Way
|
||||
|
||||
- **Stream your library** - FLAC, MP3, AAC, OGG, and other common formats work out of the box
|
||||
- **Automatic cataloging** - Lidify scans your library and enriches it with metadata from MusicBrainz and Last.fm
|
||||
- **Audio transcoding** - Stream at original quality or transcode on-the-fly (320kbps, 192kbps, or 128kbps)
|
||||
|
||||
<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._
|
||||
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 167 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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 "$@"
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 : []),
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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"));
|
||||
@@ -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();
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||