13 Commits

Author SHA1 Message Date
Your Name 74d71da230 Hotfix: Redis cache clearing, seek drag lag, session persistence (v1.3.4) 2026-01-09 20:30:55 -06:00
Your Name bddea9ef36 Hotfix: Fixed mobile session persistance 2026-01-09 19:53:13 -06:00
Your Name 0ac805b6fc chore: Release v1.3.3 - Critical bug fixes and QoL improvements
Critical Fixes:
- Docker permissions for PostgreSQL/Redis bind mounts
  Fixes #59, fixes #62
- Audio analyzer memory consumption and OOM crashes
  Fixes #21, fixes #26, fixes #53
- LastFM array normalization preventing .map crashes
  Fixes #37, fixes #39
- Wikidata 403 errors from missing User-Agent
  Fixes #57
- Singles directory creation race conditions
  Fixes #58
- Firefox FLAC playback stopping at ~4:34 mark
  Fixes #42, fixes #17

Quality of Life:
- Add Releases link to desktop sidebar navigation
  Fixes #41
- iPhone safe area insets for Dynamic Island/notch
  Fixes #54

Contributors: @arsaboo, @rustyricky, @RustyJonez, @tombatossals

No regressions detected, backward compatible, production ready.
2026-01-09 18:46:16 -06:00
Your Name ce597a318e Release v1.3.2: Fix mobile scrolling blocked by pull-to-refresh
Hotfix for v1.3.0 production issue where PullToRefresh component was
blocking all mobile scrolling.

Root Cause:
- CSS flex chain break (container uses h-full instead of flex-1)
- Touch event handlers interfering with native scroll

Fix:
Temporarily disabled PullToRefresh via early return that bypasses all
functionality while still rendering children. Feature will be properly
fixed in v1.4.

This restores normal scrolling on mobile devices across all pages.

Also includes:
- CHANGELOG.md updates for v1.3.1 and v1.3.2
- README.md typo fix
2026-01-06 21:25:31 -06:00
Your Name ffb8bda9d1 Hotfix v1.3.1: Add missing SystemSettings columns
Fix production database schema mismatch where downloadSource and
primaryFailureFallback columns were missing from SystemSettings table.

Root cause: Migration gap between squashed init migration and production
database setup timing.

Fix: Idempotent migration adds both columns if they don't exist:
- downloadSource TEXT NOT NULL DEFAULT 'soulseek'
- primaryFailureFallback TEXT NOT NULL DEFAULT 'none'
2026-01-06 21:25:31 -06:00
Kevin Allen d78eaed15b Refine note on native app development plans
Removed redundant text in the Native Apps section.
2026-01-06 20:16:25 -06:00
Your Name cc8d0f6969 Release v1.3.0: Multi-source downloads, audio analyzer resilience, mobile improvements
Major Features:
- Multi-source download system (Soulseek/Lidarr with fallback)
- Configurable enrichment speed control (1-5x)
- Mobile touch drag support for seek sliders
- iOS PWA media controls (Control Center, Lock Screen)
- Artist name alias resolution via Last.fm
- Circuit breaker pattern for audio analysis

Critical Fixes:
- Audio analyzer stability (non-ASCII, BrokenProcessPool, OOM)
- Discovery system race conditions and import failures
- Radio decade categorization using originalYear
- LastFM API response normalization
- Mood bucket infinite loop prevention

Security:
- Bull Board admin authentication
- Lidarr webhook signature verification
- JWT token expiration and refresh
- Encryption key validation on startup

Closes #2, #6, #9, #13, #21, #26, #31, #34, #35, #37, #40, #43
2026-01-06 20:07:33 -06:00
Kevin Allen 8fe151a0d1 Merge pull request #24 from Kuglikrug/patch-1
added description for NUM_WORKERS as Environment Variable
2025-12-28 15:55:17 -06:00
Kevin Allen 26d73f63a3 Merge pull request #29 from danthi123/fix/issue-12-pagination-cap
fix: remove 500/1000 item cap on library pagination
2025-12-28 14:49:33 -06:00
Daniel Thiberge e3fe3c6f48 fix: address Copilot review feedback on pagination PR
- Add MAX_LIMIT=10000 constant to prevent DoS attacks while supporting
  large libraries through pagination
- Apply MAX_LIMIT to /library/artists, /library/albums, /library/tracks
- Add trackCount field to artists endpoint (fixes "Most Tracks" sort)
- Add /library/tracks/shuffle endpoint for server-side shuffle
- Update frontend to use server-side shuffle instead of fetching 10k tracks

The shuffle endpoint uses Fisher-Yates algorithm and samples random tracks
for large libraries to avoid loading the entire database into memory.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 14:23:53 -05:00
Kevin Allen 8910ce1407 Merge pull request #15 from danthi123/fix/issue-12-pagination-cap
Remove Math.min(..., 1000) hard cap on /library/artists endpoint
Remove Math.min(..., 1000) hard cap on /library/albums endpoint
Add offset parameter and total count to /library/tracks endpoint
Rewrite useLibraryData hook for true server-side pagination
Update library page to fetch pages on demand instead of client-side slicing
Disable pagination buttons during loading to prevent race conditions
This allows libraries of any size to be fully browsable. Previously, users with 10,000+ songs were capped at seeing only 500 items.

Fixes #12
2025-12-27 11:44:34 -06:00
Kuglikrug 8dc0d3ade3 added description for NUM_WORKERS as Environment Variable 2025-12-27 15:27:47 +01:00
Daniel Thiberge 567f38e1ea fix: remove 500/1000 item cap on library pagination (Issue #12)
- Remove Math.min(..., 1000) hard cap on /library/artists endpoint
- Remove Math.min(..., 1000) hard cap on /library/albums endpoint
- Add offset parameter and total count to /library/tracks endpoint
- Rewrite useLibraryData hook for true server-side pagination
- Update library page to fetch pages on demand instead of client-side slicing
- Disable pagination buttons during loading to prevent race conditions

This allows libraries of any size to be fully browsable. Previously,
users with 10,000+ songs were capped at seeing only 500 items.

Fixes #12

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 02:44:12 -05:00
249 changed files with 21868 additions and 7950 deletions
+46 -3
View File
@@ -1,10 +1,38 @@
# Lidify Configuration # Lidify Configuration
# Copy to .env and edit as needed # Copy to .env and edit as needed
# ==============================================================================
# Database Configuration
# ==============================================================================
DATABASE_URL="postgresql://lidify:lidify@localhost:5433/lidify"
# ==============================================================================
# Redis Configuration
# ==============================================================================
# Note: Redis container port is mapped to 6380 to avoid conflicts with other Redis instances
REDIS_URL="redis://localhost:6380"
# ============================================================================== # ==============================================================================
# REQUIRED: Path to your music library # REQUIRED: Path to your music library
# ============================================================================== # ==============================================================================
MUSIC_PATH=/path/to/your/music MUSIC_PATH=/path/to/your/music
# DEVELOPMENT: Use your local path (e.g., /home/user/Music)
# DOCKER: This is the HOST path that gets mounted to /music in the container
# The backend inside Docker always uses /music, not this value.
# Example: MUSIC_PATH=~/Music (container mounts as ~/Music:/music)
# ==============================================================================
# REQUIRED: Security Keys
# ==============================================================================
# Encryption key for sensitive data (API keys, passwords, 2FA secrets)
# CRITICAL: You MUST set this before starting Lidify
# Generate with: openssl rand -base64 32
SETTINGS_ENCRYPTION_KEY=
# Session secret (auto-generated if not set)
# Generate with: openssl rand -base64 32
SESSION_SECRET=
# ============================================================================== # ==============================================================================
# OPTIONAL: Customize these if needed # OPTIONAL: Customize these if needed
@@ -16,9 +44,14 @@ PORT=3030
# Timezone (default: UTC) # Timezone (default: UTC)
TZ=UTC TZ=UTC
# Session secret (auto-generated if not set) # Logging level (default: debug in development, warn in production)
# Generate with: openssl rand -base64 32 # Options: debug, info, warn, error, silent
SESSION_SECRET= LOG_LEVEL=debug
# Allow public access to API documentation in production (default: false)
# Set to 'true' to make /api/docs accessible without authentication in production
# Development mode always allows public access
# DOCS_PUBLIC=true
# DockerHub username (for pulling images) # DockerHub username (for pulling images)
# Your DockerHub username (same as GitHub: chevron7locked) # Your DockerHub username (same as GitHub: chevron7locked)
@@ -26,3 +59,13 @@ DOCKERHUB_USERNAME=chevron7locked
# Version tag (use 'latest' or specific like 'v1.0.0') # Version tag (use 'latest' or specific like 'v1.0.0')
VERSION=latest VERSION=latest
# ==============================================================================
# OPTIONAL: Audio Analyzer CPU Control
# ==============================================================================
# Audio Analyzer CPU Control
# AUDIO_ANALYSIS_WORKERS=2 # Number of parallel worker processes (1-8)
# AUDIO_ANALYSIS_THREADS_PER_WORKER=1 # Threads per worker for TensorFlow/FFT (1-4, default 1)
# Formula: max_cpu_usage ≈ WORKERS × (THREADS_PER_WORKER + 1) × 100%
# Example: 2 workers × (1 thread + 1 overhead) = ~400% CPU (4 cores)
+102
View File
@@ -0,0 +1,102 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug", "needs triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please fill out the information below to help us diagnose and fix the issue.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Step-by-step instructions to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
placeholder: Describe what should have happened...
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
placeholder: Describe what actually happened...
validations:
required: true
- type: input
id: version
attributes:
label: Lidify Version
description: What version of Lidify are you running?
placeholder: "e.g., v1.0.0, nightly-2024-01-15, or commit hash"
validations:
required: true
- type: dropdown
id: deployment
attributes:
label: Deployment Method
description: How are you running Lidify?
options:
- Docker (docker-compose)
- Docker (standalone)
- Manual/Source
- Other
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment Details
description: Any relevant environment information (OS, browser, Docker version, etc.)
placeholder: |
- OS: Ubuntu 22.04
- Docker: 24.0.5
- Browser: Firefox 120
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: Please copy and paste any relevant log output. This will be automatically formatted into code.
render: shell
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I have searched existing issues to ensure this bug hasn't already been reported
required: true
- label: I am using a supported version of Lidify
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussions
url: https://github.com/Chevron7Locked/lidify/discussions
about: Ask questions and discuss Lidify in GitHub Discussions
@@ -0,0 +1,64 @@
name: Feature Request
description: Suggest a new feature or enhancement
title: "[Feature]: "
labels: ["enhancement", "needs triage"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please provide as much detail as possible.
- type: textarea
id: problem
attributes:
label: Problem or Use Case
description: What problem does this feature solve? What are you trying to accomplish?
placeholder: "I'm trying to... but currently..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the feature you'd like to see implemented.
placeholder: Describe your ideal solution...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?
placeholder: Describe alternatives you've considered...
validations:
required: false
- type: dropdown
id: scope
attributes:
label: Feature Scope
description: How big of a change is this?
options:
- Small (UI tweak, minor enhancement)
- Medium (new component, significant enhancement)
- Large (new major feature, architectural change)
validations:
required: true
- type: checkboxes
id: contribution
attributes:
label: Contribution
options:
- label: I would be willing to help implement this feature
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I have searched existing issues to ensure this hasn't already been requested
required: true
+37
View File
@@ -0,0 +1,37 @@
## Description
<!-- Briefly describe what this PR does -->
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Enhancement (improvement to existing functionality)
- [ ] Documentation update
- [ ] Code cleanup / refactoring
- [ ] Other (please describe):
## Related Issues
Fixes #
## Changes Made
-
-
-
## Testing Done
- [ ] Tested locally with Docker
- [ ] Tested specific functionality:
## Screenshots (if applicable)
## Checklist
- [ ] My code follows the project's code style
- [ ] I have tested my changes locally
- [ ] I have updated documentation if needed
- [ ] My changes don't introduce new warnings
- [ ] This PR targets the `main` branch
+55
View File
@@ -0,0 +1,55 @@
name: Nightly Build
on:
push:
branches: [main]
tags-ignore:
- "v*" # Don't trigger on version tags - docker-publish handles those
env:
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/lidify
jobs:
build-nightly:
name: Build & Push Nightly Image
runs-on: ubuntu-latest
steps:
- name: Checkout code
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 Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Get short SHA
id: sha
run: echo "short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build and push nightly
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.IMAGE_NAME }}:nightly
${{ env.IMAGE_NAME }}:nightly-${{ steps.sha.outputs.short }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=nightly-${{ steps.sha.outputs.short }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ARM64 disabled due to QEMU emulation issues with npm packages
platforms: linux/amd64
+1 -1
View File
@@ -1,4 +1,4 @@
name: Build and Publish Docker Image name: Release ${{ github.ref_name }}
on: on:
push: push:
+48
View File
@@ -0,0 +1,48 @@
name: PR Checks
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
jobs:
lint-frontend:
name: Lint Frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run ESLint on frontend
working-directory: frontend
run: npm run lint
build-docker:
name: Docker Build Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image (no push)
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: lidify:pr-check
cache-from: type=gha
cache-to: type=gha,mode=max
+19 -1
View File
@@ -13,6 +13,7 @@
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
.roomodes
# ============================================================================= # =============================================================================
# Dependencies # Dependencies
@@ -35,7 +36,7 @@ ENV/
**/.venv/ **/.venv/
# ============================================================================= # =============================================================================
# Build Outputs # Build
# ============================================================================= # =============================================================================
# Frontend (Next.js) # Frontend (Next.js)
frontend/.next/ frontend/.next/
@@ -316,6 +317,17 @@ bower_components
reset-and-setup.sh reset-and-setup.sh
organize-singles.sh organize-singles.sh
# AI Context Management (keep locally, don't push to GitHub)
context_portal/
# Internal Development Documentation (keep locally, don't push to GitHub)
docs/
**/docs/
# Temporary commit messages
COMMIT_MESSAGE.txt
# Backend development logs # Backend development logs
backend/logs/ backend/logs/
@@ -349,6 +361,8 @@ soularr/
**/.cursor/ **/.cursor/
.vscode/ .vscode/
**/.vscode/ **/.vscode/
.roo/
**/.roo/
# ============================================================================= # =============================================================================
# Android Build Artifacts (contains local paths) # Android Build Artifacts (contains local paths)
@@ -381,3 +395,7 @@ backend/mullvad/
# Android signing # Android signing
lidify.keystore lidify.keystore
keystore.b64 keystore.b64
.aider*
issues/
plans/
+279
View File
@@ -0,0 +1,279 @@
# ==============================================================================
# .rooignore - Custom for Lidify (Based on Context Analysis)
# ==============================================================================
# Created: 2026-01-09
# Current token usage: ~177,000 tokens per request
# Target: ~60,000-80,000 tokens per request (60% reduction)
# Expected savings: $335-395/month
# ==============================================================================
# ==============================================================================
# TEST ARTIFACTS - BIGGEST BLOAT (1.4MB found in your project)
# ==============================================================================
# Playwright test reports and results - these are generated artifacts
playwright-report/
test-results/
frontend/playwright-report/
frontend/test-results/
# Test files themselves
_.test.ts
_.test.tsx
_.test.js
_.test.jsx
_.spec.ts
_.spec.tsx
_.spec.js
_.spec.jsx
**/**tests**/
**/tests/
# ==============================================================================
# CONTEXT_PORTAL - Your RAG System (1MB of vector DB data)
# ==============================================================================
# This is YOUR context portal - Roo Code doesn't need to read it!
context_portal/
context_portal/conport_vector_data/
context_portal/context.db
\*.sqlite3
# ==============================================================================
# BUILD ARTIFACTS & CACHES (.next/ = 24MB)
# ==============================================================================
.next/
dist/
build/
out/
\*.tsbuildinfo
.turbo/
# ==============================================================================
# DEPENDENCIES - Never needed (429M backend + 729M frontend)
# ==============================================================================
node_modules/
.pnp
.pnp.js
.yarn/
# Lock files (488KB total)
package-lock.json
yarn.lock
pnpm-lock.yaml
**/node_modules/**/yarn.lock
# ==============================================================================
# IMAGES & MEDIA - (3MB+ of screenshots)
# ==============================================================================
# All image formats
_.png
_.jpg
_.jpeg
_.gif
_.webp
_.svg
_.ico
_.bmp
# Specifically your screenshot directories
assets/screenshots/
frontend/assets/splash.png
frontend/assets/splash-dark.png
# ==============================================================================
# DOCS - Large deployment doc (312KB)
# ==============================================================================
# Keep README.md, CONTRIBUTING.md, CHANGELOG.md
# Exclude large pending deploy docs
docs/PENDING_DEPLOY-1.md
# ==============================================================================
# DATABASE MIGRATIONS - Keep recent, exclude none (all are 2025+)
# ==============================================================================
# Your migrations are all from 2025/2026, so keep them all
# If you add older migrations later:
# backend/prisma/migrations/2024\*/
# ==============================================================================
# VERSION CONTROL
# ==============================================================================
.git/
.gitignore
.gitattributes
# ==============================================================================
# SOULARR - External project (separate tool)
# ==============================================================================
# If this is a separate tool/subproject, exclude it
soularr/
# ==============================================================================
# IDE & EDITOR
# ==============================================================================
.vscode/
.idea/
\*.sublime-workspace
.DS_Store
desktop.ini
# Roo-specific directories (don't need to analyze Roo's own metadata)
.roo/
.claude/
# ==============================================================================
# LOGS & TEMP
# ==============================================================================
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
_.tmp
_.temp
tmp/
temp/
# ==============================================================================
# ENVIRONMENT FILES
# ==============================================================================
.env.local
.env.\*.local
.env.production
.env.development
# ==============================================================================
# DOCKER (Keep these - you modify them)
# ==============================================================================
# Keeping Docker files as you have 8 docker-compose files
# Uncomment if you rarely modify:
# Dockerfile
# docker-compose\*.yml
# ==============================================================================
# GITHUB WORKFLOWS
# ==============================================================================
.github/workflows/
# ==============================================================================
# PYTHON CACHE (from services/audio-analyzer)
# ==============================================================================
**pycache**/
_.pyc
_.pyo
\*.pyd
.Python
# ==============================================================================
# VERIFICATION CHECKLIST
# ==============================================================================
# After adding this file:
#
# 1. Restart Roo Code
# 2. Make a simple request (e.g., "explain backend/src/routes/library.ts")
# 3. Check OpenRouter activity: https://openrouter.ai/activity
# 4. Verify token count: Should be ~60K-80K (down from 177K)
#
# If still high:
# - Check if node_modules/ is truly excluded
# - Verify .next/ is excluded
# - Check if test files are still being loaded
#
# If too aggressive (AI can't find files):
# - Remove specific exclusions one at a time
# - Start by uncommenting Docker files
# - Then uncomment docs if needed
#
# Expected cost per request:
# - Before: $0.141 (177K tokens)
# - After: $0.055-0.070 (60-80K tokens)
# - Savings: 50-60% reduction
# ==============================================================================
+274
View File
@@ -0,0 +1,274 @@
# Changelog
All notable changes to Lidify will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.3.3] - 2025-01-07
Bug fix patch release addressing 6 P1 critical issues and 2 P2 quality-of-life improvements.
### Fixed
#### Critical (P1)
- **Docker:** PostgreSQL/Redis bind mount permission errors on Linux hosts ([#59](https://github.com/Chevron7Locked/lidify/issues/59)) - @arsaboo via [#62](https://github.com/Chevron7Locked/lidify/pull/62)
- **Audio Analyzer:** Memory consumption/OOM crashes with large libraries ([#21](https://github.com/Chevron7Locked/lidify/issues/21), [#26](https://github.com/Chevron7Locked/lidify/issues/26)) - @rustyricky via [#53](https://github.com/Chevron7Locked/lidify/pull/53)
- **LastFM:** ".map is not a function" crashes with obscure artists ([#37](https://github.com/Chevron7Locked/lidify/issues/37)) - @RustyJonez via [#39](https://github.com/Chevron7Locked/lidify/pull/39)
- **Wikidata:** 403 Forbidden errors from missing User-Agent header ([#57](https://github.com/Chevron7Locked/lidify/issues/57))
- **Downloads:** Singles directory creation race conditions ([#58](https://github.com/Chevron7Locked/lidify/issues/58))
- **Firefox:** FLAC playback stopping at ~4:34 mark on large files ([#42](https://github.com/Chevron7Locked/lidify/issues/42), [#17](https://github.com/Chevron7Locked/lidify/issues/17))
#### Quality of Life (P2)
- **Desktop UI:** Added missing "Releases" link to desktop sidebar navigation ([#41](https://github.com/Chevron7Locked/lidify/issues/41))
- **iPhone:** Dynamic Island/notch overlapping TopBar buttons ([#54](https://github.com/Chevron7Locked/lidify/issues/54))
### Technical Details
- **Docker Permissions (#62):** Creates `/data/postgres` and `/data/redis` directories with proper ownership; validates write permissions at startup using `gosu <user> test -w`
- **Audio Analyzer Memory (#53):** TensorFlow GPU memory growth enabled; `MAX_ANALYZE_SECONDS` configurable (default 90s); explicit garbage collection in finally blocks
- **LastFM Normalization (#39):** `normalizeToArray()` utility wraps single-object API responses; protects 5 locations in artist discovery endpoints
- **Wikidata User-Agent (#57):** All 4 API endpoints now use configured axios client with proper User-Agent header
- **Singles Directory (#58):** Replaced TOCTOU `existsSync()`+`mkdirSync()` pattern with idempotent `mkdir({recursive: true})`
- **Firefox FLAC (#42):** Replaced Express `res.sendFile()` with manual range request handling via `fs.createReadStream()` with proper `Content-Range` headers
- **Desktop Releases (#41):** Single-line addition to Sidebar.tsx navigation array
- **iPhone Safe Area (#54):** TopBar and AuthenticatedLayout use `env(safe-area-inset-top)` CSS environment variable
### Deferred to Future Release
- **PR #49** - Playlist visibility toggle (needs PR review)
- **PR #47** - Mood bucket tags (already implemented, verify and close)
- **PR #36** - Docker --user flag (needs security review)
### Contributors
Thanks to everyone who contributed to this release:
- @arsaboo - Docker bind mount permissions fix ([#62](https://github.com/Chevron7Locked/lidify/pull/62))
- @rustyricky - Audio analyzer memory limits ([#53](https://github.com/Chevron7Locked/lidify/pull/53))
- @RustyJonez - LastFM array normalization ([#39](https://github.com/Chevron7Locked/lidify/pull/39))
- @tombatossals - Testing and validation
---
## [1.3.2] - 2025-01-07
### Fixed
- Mobile scrolling blocked by pull-to-refresh component
- Pull-to-refresh component temporarily disabled (will be properly fixed in v1.4)
### Technical Details
- Root cause: CSS flex chain break (`h-full`) and touch event interference
- Implemented early return to bypass problematic wrapper while preserving child rendering
- TODO: Re-enable in v1.4 with proper CSS fix (`flex-1 flex flex-col min-h-0`)
## [1.3.1] - 2025-01-07
### Fixed
- Production database schema mismatch causing SystemSettings endpoints to fail
- Added missing `downloadSource` and `primaryFailureFallback` columns to SystemSettings table
### Database Migrations
- `20260107000000_add_download_source_columns` - Idempotent migration adds missing columns with defaults
### Technical Details
- Root cause: Migration gap between squashed init migration and production database setup
- Uses PostgreSQL IF NOT EXISTS pattern for safe deployment across all environments
- Default values: `downloadSource='soulseek'`, `primaryFailureFallback='none'`
## [1.3.0] - 2026-01-06
### Added
- Multi-source download system with configurable Soulseek/Lidarr primary source and fallback options
- Configurable enrichment speed control (1-5x concurrency) in Settings → Cache & Automation
- Stale job cleanup button in Settings to clear stuck Discovery batches and downloads
- Mobile touch drag support for seek sliders on all player views
- Skip ±30s buttons for audiobooks/podcasts on mobile players
- iOS PWA media controls support (Control Center and Lock Screen)
- Artist name alias resolution via Last.fm (e.g., "of mice" → "Of Mice & Men")
- Library grid now supports 8 columns on ultra-wide displays (2xl breakpoint)
- Artist discography sorting options (Year/Date Added)
- Enrichment failure notifications with retry/skip modal
- Download history deduplication to prevent duplicate entries
- Utility function for normalizing API responses to arrays (`normalizeToArray`) - @tombatossals
- Keyword-based mood scoring for standard analysis mode tracks - @RustyJonez
- Global and route-level error boundaries for better error handling
- React Strict Mode for development quality checks
- Next.js image optimization enabled by default
- Mobile-aware animation rendering (GalaxyBackground disables particles on mobile)
- Accessibility motion preferences support (`prefers-reduced-motion`)
- Lazy loading for heavy components (MoodMixer, VibeOverlay, MetadataEditor)
- Bundle analyzer tooling (`npm run analyze`)
- Loading states for all 10 priority routes
- Skip links for keyboard navigation (WCAG 2.1 AA compliance)
- ARIA attributes on all interactive controls and navigation elements
- Toast notifications with ARIA live regions for screen readers
- Bull Board admin dashboard authentication (requires admin user)
- Lidarr webhook signature verification with configurable secret
- Encryption key validation on startup (prevents insecure defaults)
- Session cookie security (httpOnly, sameSite=strict, secure in production)
- Swagger API documentation authentication in production
- JWT token expiration (24h access tokens, 30d refresh tokens)
- JWT refresh token endpoint (`/api/auth/refresh`)
- Token version validation (password changes invalidate existing tokens)
- Download queue reconciliation on server startup (marks stale jobs as failed)
- Redis batch operations for cache warmup (MULTI/EXEC pipelining)
- Memory-efficient database-level shuffle (`ORDER BY RANDOM() LIMIT n`)
- Dynamic import caching in queue cleaner (lazy-load pattern)
- Database index for `DownloadJob.targetMbid` field
- PWA install prompt dismissal persistence (7-day cooldown)
### Fixed
- **Critical:** Audio analyzer crashes on libraries with non-ASCII filenames ([#6](https://github.com/Chevron7Locked/lidify/issues/6))
- **Critical:** Audio analyzer BrokenProcessPool after ~1900 tracks ([#21](https://github.com/Chevron7Locked/lidify/issues/21))
- **Critical:** Audio analyzer OOM kills with aggressive worker auto-scaling ([#26](https://github.com/Chevron7Locked/lidify/issues/26))
- **Critical:** Audio analyzer model downloads and volume mount conflicts ([#2](https://github.com/Chevron7Locked/lidify/issues/2))
- Radio stations playing songs from wrong decades due to remaster dates ([#43](https://github.com/Chevron7Locked/lidify/issues/43))
- Manual metadata editing failing with 500 errors ([#9](https://github.com/Chevron7Locked/lidify/issues/9))
- Active downloads not resolving after Lidarr successfully imports ([#31](https://github.com/Chevron7Locked/lidify/issues/31))
- Discovery playlist downloads failing for artists with large catalogs ([#34](https://github.com/Chevron7Locked/lidify/issues/34))
- Discovery batches stuck in "downloading" status indefinitely
- Audio analyzer rhythm extraction failures on short/silent audio ([#13](https://github.com/Chevron7Locked/lidify/issues/13))
- "Of Mice & Men" artist name truncated to "Of Mice" during scanning
- Edition variant albums (Remastered, Deluxe) failing with "No releases available"
- Downloads stuck in "Lidarr #1" state for 5 minutes before failing
- Download duplicate prevention race condition causing 10+ duplicate jobs
- Lidarr downloads incorrectly cancelled during temporary network issues
- Discovery Weekly track durations showing "NaN:NaN"
- Artist name search ampersand handling ("Earth, Wind & Fire")
- Vibe overlay display issues on mobile devices
- Pagination scroll behavior (now scrolls to top instead of bottom)
- LastFM API crashes when receiving single objects instead of arrays ([#37](https://github.com/Chevron7Locked/lidify/issues/37)) - @tombatossals
- Mood bucket infinite loop for tracks analyzed in standard mode ([#40](https://github.com/Chevron7Locked/lidify/issues/40)) - @RustyJonez
- Playlist visibility toggle not properly syncing hide/show state - @tombatossals
- Audio player time display showing current time exceeding total duration (e.g., "58:00 / 54:34")
- Progress bar could exceed 100% for long-form media with stale metadata
- Enrichment P2025 errors when retrying enrichment for deleted entities
- Download settings fallback not resetting when changing primary source
- SeekSlider touch events bubbling to parent OverlayPlayer swipe handlers
- Audiobook/podcast position showing 0:00 after page refresh instead of saved progress
- Volume slider showing no visual fill indicator for current level
- PWA install prompt reappearing after user dismissal
### Changed
- Audio analyzer default workers reduced from auto-scale to 2 (memory conservative)
- Audio analyzer Docker memory limits: 6GB limit, 2GB reservation
- Download status polling intervals: 5s (active) / 10s (idle) / 30s (none), previously 15s
- Library pagination options changed to 24/40/80/200 (divisible by 8-column grid)
- Lidarr download failure detection now has 90-second grace period (3 checks)
- Lidarr catalog population timeout increased from 45s to 60s
- Download notifications now use API-driven state instead of local pending state
- Enrichment stop button now gracefully finishes current item before stopping
- Per-album enrichment triggers immediately instead of waiting for batch completion
- Lidarr edition variant detection now proactive (enables `anyReleaseOk` before first search)
- Discovery system now uses AcquisitionService for unified album/track acquisition
- Podcast and audiobook time display now shows time remaining instead of total duration
- Edition variant albums automatically fall back to base title search when edition-specific search fails
- Stale pending downloads cleaned up after 2 minutes (was indefinite)
- Download source detection now prioritizes actual service availability over user preference
### Removed
- Artist delete buttons hidden on mobile to prevent accidental deletion
- Audio analyzer models volume mount (shadowed built-in models)
### Database Migrations Required
```bash
# Run Prisma migrations
cd backend
npx prisma migrate deploy
```
**New Schema Fields:**
- `Album.originalYear` - Stores original release year (separate from remaster dates)
- `SystemSettings.enrichmentConcurrency` - User-configurable enrichment speed (1-5)
- `SystemSettings.downloadSource` - Primary download source selection
- `SystemSettings.primaryFailureFallback` - Fallback behavior on primary source failure
- `SystemSettings.lidarrWebhookSecret` - Shared secret for Lidarr webhook signature verification
- `User.tokenVersion` - Version number for JWT token invalidation on password change
- `DownloadJob.targetMbid` - Index added for improved query performance
**Backfill Script (Optional):**
```bash
# Backfill originalYear for existing albums
cd backend
npx ts-node scripts/backfill-original-year.ts
```
### Breaking Changes
- None - All changes are backward compatible
### Security
- **Critical:** Bull Board admin dashboard now requires authenticated admin user
- **Critical:** Lidarr webhooks verify signature/secret before processing requests
- **Critical:** Encryption key validation on startup prevents insecure defaults
- Session cookies use secure settings in production (httpOnly, sameSite=strict, secure)
- Swagger API documentation requires authentication in production (unless `DOCS_PUBLIC=true`)
- JWT tokens have proper expiration (24h access, 30d refresh) with refresh token support
- Password changes invalidate all existing tokens via tokenVersion increment
- Transaction-based download job creation prevents race conditions
- Enrichment stop control no longer bypassed by worker state
- Download queue webhook handlers use Serializable isolation transactions
- Webhook race conditions protected with exponential backoff retry logic
---
## Release Notes
When deploying this update:
1. **Backup your database** before running migrations
2. **Set required environment variable** (if not already set):
```bash
# Generate secure encryption key
SETTINGS_ENCRYPTION_KEY=$(openssl rand -base64 32)
```
3. Run `npx prisma migrate deploy` in the backend directory
4. Optionally run the originalYear backfill script for era mix accuracy:
```bash
cd backend
npx ts-node scripts/backfill-original-year.ts
```
5. Clear Docker volumes for audio-analyzer if experiencing model issues:
```bash
docker volume rm lidify_audio_analyzer_models 2>/dev/null || true
docker compose build audio-analyzer --no-cache
```
6. Review Settings → Downloads for new multi-source download options
7. Review Settings → Cache for new enrichment speed control
8. Configure Lidarr webhook secret in Settings for webhook signature verification (recommended)
9. Review Settings → Security for JWT token settings
### Known Issues
- Pre-existing TypeScript errors in spotifyImport.ts matchTrack method (unrelated to this release)
- Simon & Garfunkel artist name may be truncated due to short second part (edge case, not blocking)
### Contributors
Big thanks to everyone who contributed, tested, and helped make this release happen:
- @tombatossals - LastFM API normalization utility ([#39](https://github.com/Chevron7Locked/lidify/pull/39)), playlist visibility toggle fix ([#49](https://github.com/Chevron7Locked/lidify/pull/49))
- @RustyJonez - Mood bucket standard mode keyword scoring ([#47](https://github.com/Chevron7Locked/lidify/pull/47))
- @iamiq - Audio analyzer crash reporting ([#2](https://github.com/Chevron7Locked/lidify/issues/2))
- @volcs0 - Memory pressure testing ([#26](https://github.com/Chevron7Locked/lidify/issues/26))
- @Osiriz - Long-running analysis testing ([#21](https://github.com/Chevron7Locked/lidify/issues/21))
- @hessonam - Non-ASCII character testing ([#6](https://github.com/Chevron7Locked/lidify/issues/6))
- @niles - RhythmExtractor edge case reporting ([#13](https://github.com/Chevron7Locked/lidify/issues/13))
- @TheChrisK - Metadata editor bug reporting ([#9](https://github.com/Chevron7Locked/lidify/issues/9))
- @lizar93 - Discovery playlist testing ([#34](https://github.com/Chevron7Locked/lidify/issues/34))
- @brokenglasszero - Mood tags feature verification ([#35](https://github.com/Chevron7Locked/lidify/issues/35))
And all users who reported bugs, tested fixes, and provided feedback!
---
For detailed technical implementation notes, see [docs/PENDING_DEPLOY.md](docs/PENDING_DEPLOY.md).
+63
View File
@@ -0,0 +1,63 @@
# Contributing to Lidify
First off, thanks for taking the time to contribute! 🎉
## Getting Started
1. Fork the repository
2. Clone your fork locally
3. Set up the development environment (see README.md)
4. Create a new branch from `main` for your changes
## Branch Strategy
All development happens on the `main` branch:
- **All PRs should target `main`**
- Every push to `main` triggers a nightly Docker build
- Stable releases are created via version tags
## Making Contributions
### Bug Fixes
1. Check existing issues to see if the bug has been reported
2. If not, open a bug report issue first
3. Fork, branch, fix, and submit a PR referencing the issue
### Small Enhancements
1. Open a feature request issue to discuss first
2. Keep changes focused and minimal
### Large Features
Please open an issue to discuss before starting work.
## Code Style
### Frontend
The frontend uses ESLint. Before submitting a PR:
```bash
cd frontend
npm run lint
```
### Backend
Follow existing code patterns and TypeScript conventions.
## Pull Request Process
1. **Target the `main` branch**
2. Fill out the PR template completely
3. Ensure the Docker build check passes
4. Wait for review - we'll provide feedback or approve
## Questions?
Open a Discussion thread for questions that aren't bugs or feature requests.
Thanks for contributing!
+137 -45
View File
@@ -48,35 +48,73 @@ RUN pip3 install --no-cache-dir --break-system-packages \
psycopg2-binary psycopg2-binary
# Download Essentia ML models (~200MB total) - these enable Enhanced vibe matching # Download Essentia ML models (~200MB total) - these enable Enhanced vibe matching
# IMPORTANT: Using MusiCNN models to match analyzer.py expectations
RUN echo "Downloading Essentia ML models for Enhanced vibe matching..." && \ RUN echo "Downloading Essentia ML models for Enhanced vibe matching..." && \
# Base embedding model (required for all predictions) # Base MusiCNN embedding model (required for all predictions)
curl -L --progress-bar -o /app/models/discogs-effnet-bs64-1.pb \ curl -L --progress-bar -o /app/models/msd-musicnn-1.pb \
"https://essentia.upf.edu/models/feature-extractors/discogs-effnet/discogs-effnet-bs64-1.pb" && \ "https://essentia.upf.edu/models/autotagging/msd/msd-musicnn-1.pb" && \
# Mood models # Mood classification heads (using MusiCNN architecture)
curl -L --progress-bar -o /app/models/mood_happy-discogs-effnet-1.pb \ curl -L --progress-bar -o /app/models/mood_happy-msd-musicnn-1.pb \
"https://essentia.upf.edu/models/classification-heads/mood_happy/mood_happy-discogs-effnet-1.pb" && \ "https://essentia.upf.edu/models/classification-heads/mood_happy/mood_happy-msd-musicnn-1.pb" && \
curl -L --progress-bar -o /app/models/mood_sad-discogs-effnet-1.pb \ curl -L --progress-bar -o /app/models/mood_sad-msd-musicnn-1.pb \
"https://essentia.upf.edu/models/classification-heads/mood_sad/mood_sad-discogs-effnet-1.pb" && \ "https://essentia.upf.edu/models/classification-heads/mood_sad/mood_sad-msd-musicnn-1.pb" && \
curl -L --progress-bar -o /app/models/mood_relaxed-discogs-effnet-1.pb \ curl -L --progress-bar -o /app/models/mood_relaxed-msd-musicnn-1.pb \
"https://essentia.upf.edu/models/classification-heads/mood_relaxed/mood_relaxed-discogs-effnet-1.pb" && \ "https://essentia.upf.edu/models/classification-heads/mood_relaxed/mood_relaxed-msd-musicnn-1.pb" && \
curl -L --progress-bar -o /app/models/mood_aggressive-discogs-effnet-1.pb \ curl -L --progress-bar -o /app/models/mood_aggressive-msd-musicnn-1.pb \
"https://essentia.upf.edu/models/classification-heads/mood_aggressive/mood_aggressive-discogs-effnet-1.pb" && \ "https://essentia.upf.edu/models/classification-heads/mood_aggressive/mood_aggressive-msd-musicnn-1.pb" && \
# Arousal and Valence (key for vibe matching) curl -L --progress-bar -o /app/models/mood_party-msd-musicnn-1.pb \
curl -L --progress-bar -o /app/models/mood_arousal-discogs-effnet-1.pb \ "https://essentia.upf.edu/models/classification-heads/mood_party/mood_party-msd-musicnn-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_acoustic-msd-musicnn-1.pb \
curl -L --progress-bar -o /app/models/mood_valence-discogs-effnet-1.pb \ "https://essentia.upf.edu/models/classification-heads/mood_acoustic/mood_acoustic-msd-musicnn-1.pb" && \
"https://essentia.upf.edu/models/classification-heads/mood_valence/mood_valence-discogs-effnet-1.pb" && \ curl -L --progress-bar -o /app/models/mood_electronic-msd-musicnn-1.pb \
# Danceability and Voice/Instrumental "https://essentia.upf.edu/models/classification-heads/mood_electronic/mood_electronic-msd-musicnn-1.pb" && \
curl -L --progress-bar -o /app/models/danceability-discogs-effnet-1.pb \ # Other classification heads
"https://essentia.upf.edu/models/classification-heads/danceability/danceability-discogs-effnet-1.pb" && \ curl -L --progress-bar -o /app/models/danceability-msd-musicnn-1.pb \
curl -L --progress-bar -o /app/models/voice_instrumental-discogs-effnet-1.pb \ "https://essentia.upf.edu/models/classification-heads/danceability/danceability-msd-musicnn-1.pb" && \
"https://essentia.upf.edu/models/classification-heads/voice_instrumental/voice_instrumental-discogs-effnet-1.pb" && \ curl -L --progress-bar -o /app/models/voice_instrumental-msd-musicnn-1.pb \
"https://essentia.upf.edu/models/classification-heads/voice_instrumental/voice_instrumental-msd-musicnn-1.pb" && \
echo "ML models downloaded successfully" && \ echo "ML models downloaded successfully" && \
ls -lh /app/models/ ls -lh /app/models/
# Copy audio analyzer script # Copy audio analyzer script
COPY services/audio-analyzer/analyzer.py /app/audio-analyzer/ COPY services/audio-analyzer/analyzer.py /app/audio-analyzer/
# Create database readiness check script
RUN cat > /app/wait-for-db.sh << 'EOF'
#!/bin/bash
TIMEOUT=${1:-120}
COUNTER=0
echo "[wait-for-db] Waiting for database schema (timeout: ${TIMEOUT}s)..."
# Quick check for schema ready flag
if [ -f /data/.schema_ready ]; then
echo "[wait-for-db] Schema ready flag found, verifying connection..."
fi
while [ $COUNTER -lt $TIMEOUT ]; do
if PGPASSWORD=lidify psql -h localhost -U lidify -d lidify -c "SELECT 1 FROM \"Track\" LIMIT 1" > /dev/null 2>&1; then
echo "[wait-for-db] ✓ Database is ready and schema exists!"
exit 0
fi
if [ $((COUNTER % 15)) -eq 0 ]; then
echo "[wait-for-db] Still waiting... (${COUNTER}s elapsed)"
fi
sleep 1
COUNTER=$((COUNTER + 1))
done
echo "[wait-for-db] ERROR: Database schema not ready after ${TIMEOUT}s"
echo "[wait-for-db] Listing available tables:"
PGPASSWORD=lidify psql -h localhost -U lidify -d lidify -c "\dt" 2>&1 || echo "Could not list tables"
exit 1
EOF
RUN chmod +x /app/wait-for-db.sh && \
sed -i 's/\r$//' /app/wait-for-db.sh
# ============================================ # ============================================
# BACKEND BUILD # BACKEND BUILD
# ============================================ # ============================================
@@ -155,6 +193,7 @@ priority=10
[program:redis] [program:redis]
command=/usr/bin/redis-server --dir /data/redis --appendonly yes command=/usr/bin/redis-server --dir /data/redis --appendonly yes
user=redis
autostart=true autostart=true
autorestart=true autorestart=true
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
@@ -164,9 +203,11 @@ stderr_logfile_maxbytes=0
priority=20 priority=20
[program:backend] [program:backend]
command=/bin/bash -c "sleep 5 && cd /app/backend && npx tsx src/index.ts" command=/bin/bash -c "/app/wait-for-db.sh 120 && cd /app/backend && npx tsx src/index.ts"
autostart=true autostart=true
autorestart=true autorestart=unexpected
startretries=3
startsecs=10
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
@@ -186,14 +227,16 @@ environment=NODE_ENV="production",BACKEND_URL="http://localhost:3006",PORT="3030
priority=40 priority=40
[program:audio-analyzer] [program:audio-analyzer]
command=/bin/bash -c "sleep 15 && cd /app/audio-analyzer && python3 analyzer.py" command=/bin/bash -c "/app/wait-for-db.sh 120 && cd /app/audio-analyzer && python3 analyzer.py"
autostart=true autostart=true
autorestart=true autorestart=unexpected
startretries=3
startsecs=10
stdout_logfile=/dev/stdout stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0 stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 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" environment=DATABASE_URL="postgresql://lidify:lidify@localhost:5432/lidify",REDIS_URL="redis://localhost:6379",MUSIC_PATH="/music",BATCH_SIZE="10",SLEEP_INTERVAL="5",MAX_ANALYZE_SECONDS="90"
priority=50 priority=50
EOF EOF
@@ -232,10 +275,33 @@ if [ -z "$PG_BIN" ]; then
fi fi
echo "Using PostgreSQL from: $PG_BIN" echo "Using PostgreSQL from: $PG_BIN"
# Fix permissions on data directories (may have different UID from previous container) # Prepare data directories (bind-mount safe)
echo "Fixing data directory permissions..." echo "Preparing data directories..."
chown -R postgres:postgres /data/postgres /run/postgresql 2>/dev/null || true mkdir -p /data/postgres /data/redis /run/postgresql
chmod 700 /data/postgres 2>/dev/null || true
if id postgres >/dev/null 2>&1; then
chown -R postgres:postgres /data/postgres /run/postgresql 2>/dev/null || true
chmod 700 /data/postgres 2>/dev/null || true
if ! gosu postgres test -w /data/postgres; then
POSTGRES_UID=$(id -u postgres)
POSTGRES_GID=$(id -g postgres)
echo "ERROR: /data/postgres is not writable by postgres (${POSTGRES_UID}:${POSTGRES_GID})."
echo "If you bind-mount /data, ensure the host path is writable by that UID/GID."
exit 1
fi
fi
if id redis >/dev/null 2>&1; then
chown -R redis:redis /data/redis 2>/dev/null || true
chmod 700 /data/redis 2>/dev/null || true
if ! gosu redis test -w /data/redis; then
REDIS_UID=$(id -u redis)
REDIS_GID=$(id -g redis)
echo "ERROR: /data/redis is not writable by redis (${REDIS_UID}:${REDIS_GID})."
echo "If you bind-mount /data, ensure the host path is writable by that UID/GID."
exit 1
fi
fi
# Clean up stale PID file if exists # Clean up stale PID file if exists
rm -f /data/postgres/postmaster.pid 2>/dev/null || true rm -f /data/postgres/postmaster.pid 2>/dev/null || true
@@ -271,32 +337,53 @@ MIGRATIONS_EXIST=$(gosu postgres psql -d lidify -tAc "SELECT EXISTS (SELECT FROM
# Check if User table exists (indicates existing data) # 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") 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")
# Handle rename migration for existing databases
echo "Checking if rename migration needs to be marked as applied..."
if gosu postgres psql -d lidify -tAc "SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='SystemSettings' AND column_name='soulseekFallback');" 2>/dev/null | grep -q 't'; then
echo "Old column exists, marking migration as applied..."
gosu postgres psql -d lidify -c "INSERT INTO \"_prisma_migrations\" (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count) VALUES (gen_random_uuid(), '', NOW(), '20250101000000_rename_soulseek_fallback', '', NULL, NOW(), 1) ON CONFLICT DO NOTHING;" 2>/dev/null || true
fi
if [ "$MIGRATIONS_EXIST" = "t" ]; then if [ "$MIGRATIONS_EXIST" = "t" ]; then
# Normal migration flow - migrations table exists # Normal migration flow - migrations table exists
echo "Migration history found, running migrate deploy..." echo "Migration history found, running migrate deploy..."
npx prisma migrate deploy 2>&1 || { if ! npx prisma migrate deploy 2>&1; then
echo "WARNING: Migration failed, but database preserved." echo "FATAL: Database migration failed! Check logs above."
echo "You may need to manually resolve migration issues." exit 1
} fi
elif [ "$USER_TABLE_EXIST" = "t" ]; then elif [ "$USER_TABLE_EXIST" = "t" ]; then
# Database has data but no migrations table - needs baseline # Database has data but no migrations table - needs baseline
echo "Existing database detected without migration history." echo "Existing database detected without migration history."
echo "Creating baseline from current schema..." echo "Creating baseline from current schema..."
# Mark the init migration as already applied (baseline) # Mark the init migration as already applied (baseline)
npx prisma migrate resolve --applied 20251130000000_init 2>&1 || true npx prisma migrate resolve --applied 20241130000000_init 2>&1 || true
# Now run any subsequent migrations # Now run any subsequent migrations
npx prisma migrate deploy 2>&1 || { if ! npx prisma migrate deploy 2>&1; then
echo "WARNING: Migration after baseline failed." echo "FATAL: Migration after baseline failed!"
echo "Database preserved - check migration status manually." exit 1
} fi
else else
# Fresh database - run migrations normally # Fresh database - run migrations normally
echo "Fresh database detected, running initial migrations..." echo "Fresh database detected, running initial migrations..."
npx prisma migrate deploy 2>&1 || { if ! npx prisma migrate deploy 2>&1; then
echo "WARNING: Initial migration failed." echo "FATAL: Initial migration failed. Check database connection and schema."
echo "Check database connection and schema." exit 1
} fi
fi fi
echo "✓ Migrations completed successfully"
# Verify schema exists before starting services
echo "Verifying database schema..."
if ! gosu postgres psql -d lidify -c "SELECT 1 FROM \"Track\" LIMIT 1" >/dev/null 2>&1; then
echo "FATAL: Track table does not exist after migration!"
echo "Database schema verification failed. Container will exit."
exit 1
fi
echo "✓ Schema verification passed"
# Create flag file for wait-for-db.sh
touch /data/.schema_ready
echo "✓ Schema ready flag created"
# Stop PostgreSQL (supervisord will start it) # Stop PostgreSQL (supervisord will start it)
gosu postgres $PG_BIN/pg_ctl -D /data/postgres -w stop gosu postgres $PG_BIN/pg_ctl -D /data/postgres -w stop
@@ -338,7 +425,12 @@ SETTINGS_ENCRYPTION_KEY=$SETTINGS_ENCRYPTION_KEY
ENVEOF ENVEOF
echo "Starting Lidify..." echo "Starting Lidify..."
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf exec env \
NODE_ENV=production \
DATABASE_URL="postgresql://lidify:lidify@localhost:5432/lidify" \
SESSION_SECRET="$SESSION_SECRET" \
SETTINGS_ENCRYPTION_KEY="$SETTINGS_ENCRYPTION_KEY" \
/usr/bin/supervisord -c /etc/supervisor/supervisord.conf
EOF EOF
# Fix Windows line endings (CRLF -> LF) and make executable # Fix Windows line endings (CRLF -> LF) and make executable
+122 -32
View File
@@ -14,7 +14,7 @@ Lidify is built for music lovers who want the convenience of streaming services
## A Note on Native Apps ## A Note on Native Apps
I got a little 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. 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. Thanks for your patience while I work through this.
@@ -45,6 +45,7 @@ Thanks for your patience while I work through this.
- **Stream your library** - FLAC, MP3, AAC, OGG, and other common formats work out of the box - **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 - **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) - **Audio transcoding** - Stream at original quality or transcode on-the-fly (320kbps, 192kbps, or 128kbps)
- **Ultra-wide support** - Library grid scales up to 8 columns on large displays
<p align="center"> <p align="center">
<img src="assets/screenshots/desktop-library.png" alt="Library View" width="800"> <img src="assets/screenshots/desktop-library.png" alt="Library View" width="800">
@@ -66,6 +67,8 @@ Thanks for your patience while I work through this.
- Dynamic genre and decade stations generated from your library - Dynamic genre and decade stations generated from your library
- **Discover Weekly** - Weekly playlists of new music tailored to your listening habits (requires Lidarr) - **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 - **Artist recommendations** - Find similar artists based on what you already love
- **Artist name resolution** - Smart alias lookup via Last.fm (e.g., "of mice" → "Of Mice & Men")
- **Discography sorting** - Sort artist albums by year or date added
- **Deezer previews** - Preview tracks you don't own before adding them to your library - **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)) - **Vibe matching** - Find tracks that match your current mood (see [The Vibe System](#the-vibe-system))
@@ -74,6 +77,7 @@ Thanks for your patience while I work through this.
- **Subscribe via RSS** - Search iTunes for podcasts and subscribe directly - **Subscribe via RSS** - Search iTunes for podcasts and subscribe directly
- **Track progress** - Pick up where you left off across devices - **Track progress** - Pick up where you left off across devices
- **Episode management** - Browse episodes, mark as played, and manage your subscriptions - **Episode management** - Browse episodes, mark as played, and manage your subscriptions
- **Mobile skip buttons** - Jump ±30 seconds on mobile for easy navigation
<p align="center"> <p align="center">
<img src="assets/screenshots/desktop-podcasts.png" alt="Podcasts" width="800"> <img src="assets/screenshots/desktop-podcasts.png" alt="Podcasts" width="800">
@@ -84,6 +88,7 @@ Thanks for your patience while I work through this.
- **Audiobookshelf integration** - Connect your existing Audiobookshelf instance - **Audiobookshelf integration** - Connect your existing Audiobookshelf instance
- **Unified experience** - Browse and listen to audiobooks alongside your music - **Unified experience** - Browse and listen to audiobooks alongside your music
- **Progress sync** - Your listening position syncs with Audiobookshelf - **Progress sync** - Your listening position syncs with Audiobookshelf
- **Mobile skip buttons** - Jump ±30 seconds on mobile for easy chapter navigation
<p align="center"> <p align="center">
<img src="assets/screenshots/desktop-audiobooks.png" alt="Audiobooks" width="800"> <img src="assets/screenshots/desktop-audiobooks.png" alt="Audiobooks" width="800">
@@ -172,7 +177,7 @@ Lidify works as a PWA on mobile devices, giving you a native app-like experience
- Full streaming functionality - Full streaming functionality
- Background audio playback - Background audio playback
- Lock screen / notification media controls (via Media Session API) - Lock screen and notification media controls (iOS Control Center and Android notifications)
- Offline caching for faster loads - Offline caching for faster loads
- Installable icon on home screen - Installable icon on home screen
@@ -270,23 +275,67 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
### Bind-mounting `/data` on Linux
Named volumes are recommended. If you bind-mount `/data`, make sure required subdirectories exist and are writable by the container service users.
```bash
mkdir -p /path/to/lidify-data/postgres /path/to/lidify-data/redis
```
If startup logs report a permission error, `chown` the host path to the UID/GID shown in the logs (for example, the postgres user).
--- ---
Lidify will begin scanning your music library automatically. Depending on the size of your collection, this may take a few minutes to several hours. Lidify will begin scanning your music library automatically. Depending on the size of your collection, this may take a few minutes to several hours.
--- ---
## Release Channels
Lidify offers two release channels to match your stability preferences:
### 🟢 Stable (Recommended)
Production-ready releases. Updated when new stable versions are released.
```bash
docker pull chevron7locked/lidify:latest
# or specific version
docker pull chevron7locked/lidify:v1.2.0
```
### 🔴 Nightly (Development)
Latest development build. Built on every push to main.
⚠️ **Not recommended for production** - may be unstable or broken.
```bash
docker pull chevron7locked/lidify:nightly
```
**For contributors:** See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on submitting pull requests and contributing to Lidify.
---
## Configuration ## Configuration
### Environment Variables ### Environment Variables
The unified Lidify container handles most configuration automatically. Here are the available options: The unified Lidify container handles most configuration automatically. Here are the available options:
| Variable | Default | Description | | Variable | Default | Description |
| --------------------- | ---------------------------------- | --------------------------------------------------------------------------- | | ----------------------------------- | ---------------------------------- | --------------------------------------------------------------------------- |
| `SESSION_SECRET` | Auto-generated | Session encryption key (recommended to set for persistence across restarts) | | `SESSION_SECRET` | Auto-generated | Session encryption key (recommended to set for persistence across restarts) |
| `TZ` | `UTC` | Timezone for the container | | `SETTINGS_ENCRYPTION_KEY` | Required | Encryption key for stored credentials (generate with `openssl rand -base64 32`) |
| `LIDIFY_CALLBACK_URL` | `http://host.docker.internal:3030` | URL for Lidarr webhook callbacks (see [Lidarr integration](#lidarr)) | | `TZ` | `UTC` | Timezone for the container |
| `PORT` | `3030` | Port to access Lidify |
| `LIDIFY_CALLBACK_URL` | `http://host.docker.internal:3030` | URL for Lidarr webhook callbacks (see [Lidarr integration](#lidarr)) |
| `AUDIO_ANALYSIS_WORKERS` | `2` | Number of parallel workers for audio analysis (1-8) |
| `AUDIO_ANALYSIS_THREADS_PER_WORKER` | `1` | Threads per worker for TensorFlow/FFT operations (1-4) |
| `LOG_LEVEL` | `warn` (prod) / `debug` (dev) | Logging verbosity: debug, info, warn, error, silent |
| `DOCS_PUBLIC` | `false` | Set to `true` to allow public access to API docs in production |
The music library path is configured via Docker volume mount (`-v /path/to/music:/music`). The music library path is configured via Docker volume mount (`-v /path/to/music:/music`).
@@ -312,42 +361,58 @@ ALLOWED_ORIGINS=http://localhost:3030,https://lidify.yourdomain.com
Lidify uses several sensitive environment variables. Never commit your `.env` file. Lidify uses several sensitive environment variables. Never commit your `.env` file.
| Variable | Purpose | Required | | Variable | Purpose | Required |
| ------------------------- | ------------------------------ | ------------------ | | ------------------------- | ------------------------------ | ----------------- |
| `SESSION_SECRET` | Session encryption (32+ chars) | Yes | | `SESSION_SECRET` | Session encryption (32+ chars) | Yes |
| `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Recommended | | `SETTINGS_ENCRYPTION_KEY` | Encrypts stored credentials | Yes |
| `SOULSEEK_USERNAME` | Soulseek login | If u sing Soulseek | | `SOULSEEK_USERNAME` | Soulseek login | If using Soulseek |
| `SOULSEEK_PASSWORD`- | Soulseek password - | If using S-oulseek | | `SOULSEEK_PASSWORD` | Soulseek password | If using Soulseek |
| `LIDARR_AP I_KEY` | Lidarr integration | If using L idarr | | `LIDARR_API_KEY` | Lidarr integration | If using Lidarr |
| `OPENAI_API_KEY` | AI features | Optional | | `OPENAI_API_KEY` | AI features | Optional |
| `LASTFM_API_KEY ` | Artist recommendations | Optional | | `LASTFM_API_KEY` | Artist recommendations | Optional |
| `FANART_API_KEY` | Artist images | Optional | | `FANART_API_KEY` | Artist images | Optional |
### VPN Configurati on (Optional) ### Authentication & Session Security
- **JWT tokens** - Access tokens expire after 24 hours; refresh tokens after 30 days
- **Token refresh** - Automatic token refresh via `/api/auth/refresh` endpoint
- **Password changes** - Changing your password invalidates all existing sessions
- **Session cookies** - Secured with `httpOnly`, `sameSite=strict`, and `secure` (in production)
- **Encryption validation** - Encryption key is validated on startup to prevent insecure defaults
### Webhook Security
- **Lidarr webhooks** - Support signature verification with configurable secret
- Configure the webhook secret in Settings → Lidarr for additional security
### Admin Dashboard Security
- **Bull Board** - Job queue dashboard at `/admin/queues` requires authenticated admin user
- **API Documentation** - Swagger docs at `/api-docs` require authentication in production (unless `DOCS_PUBLIC=true`)
### VPN Configuration (Optional)
If using Mullvad VPN for Soulseek: If using Mullvad VPN for Soulseek:
- Place Wi reGuard config in `ba ckend/mullvad/` (gitignored) - Place WireGuard config in `backend/mullvad/` (gitignored)
- Never commit VPN cred entials or private keys - Never commit VPN credentials or private keys
- The `*.conf` and `key.txt` patterns are already in .git ignore - The `*.conf` and `key.txt` patterns are already in .gitignore
### Generating Secrets ### Generating Secrets
```bas h ```bash
# Generate a secure session secret # Generate a secure session secret
openss l rand - base64 32 openssl rand -base64 32
# Generate encryption key # Generate encryption key
openssl rand -hex 32 openssl rand -base64 32
``` ```
### Network ### Network Security
Sec urity
- Lidify is designed for self-hosted LAN use - Lidify is designed for self-hosted LAN use
- For exte rnal access, use a reverse proxy with HTTPS - For external access, use a reverse proxy with HTTPS
- C o nfigure `ALLOWED_ORIGINS` for your domain - Configure `ALLOWED_ORIGINS` for your domain
--- ---
@@ -357,12 +422,12 @@ Lidify works beautifully on its own, but it becomes even more powerful when conn
### Lidarr ### Lidarr
Connect Lidify to your Lidarr instance to request and downloa d new music directly from the app. Connect Lidify to your Lidarr instance to request and download new music directly from the app.
**What you get:** **What you get:**
- Browse artists and albums you don't own - Browse artists and albums you don't own
- Request downloads with a single click - Request downloads with a single click
- Discover Weekly playlists that automatically download new recommendations - Discover Weekly playlists that automatically download new recommendations
- Automatic library sync when Lidarr finishes importing - Automatic library sync when Lidarr finishes importing
@@ -574,6 +639,22 @@ Administrators have access to additional settings:
- **Cache Management** - Clear caches if needed - **Cache Management** - Clear caches if needed
- **Advanced** - Download retry settings, concurrent download limits - **Advanced** - Download retry settings, concurrent download limits
### Download Settings
Configure how Lidify acquires new music in Settings → Downloads:
- **Primary Source** - Choose between Soulseek or Lidarr as your main download source
- **Fallback Behavior** - Optionally fall back to the other source if the primary fails
- **Stale Job Cleanup** - Clear stuck Discovery batches and downloads that aren't progressing
### Enrichment Settings
Control metadata enrichment in Settings → Cache & Automation:
- **Enrichment Speed** - Adjust concurrency (1-5x) to balance speed vs. system load
- **Failure Notifications** - Get notified when enrichment fails for specific items
- **Retry/Skip Modal** - Choose to retry failed items or skip them to continue processing
### Activity Panel ### Activity Panel
The Activity Panel provides real-time visibility into downloads and system events: The Activity Panel provides real-time visibility into downloads and system events:
@@ -592,7 +673,16 @@ For programmatic access to Lidify:
2. Generate a new key with a descriptive name 2. Generate a new key with a descriptive name
3. Use the key in the `Authorization` header: `Bearer YOUR_API_KEY` 3. Use the key in the `Authorization` header: `Bearer YOUR_API_KEY`
API documentation is available at `/api-docs` when the backend is running. API documentation is available at `/api-docs` when the backend is running (requires authentication in production).
### Bull Board Dashboard
Monitor background job queues at `/admin/queues`:
- View active, waiting, completed, and failed jobs
- Retry or remove stuck jobs
- Monitor download progress and enrichment tasks
- Requires admin authentication
--- ---
+200
View File
@@ -0,0 +1,200 @@
#!/bin/bash
# ==============================================================================
# analyze-context-bloat.sh
# ==============================================================================
# Purpose: Find large files in your project that are bloating Roo Code context
# Usage: Run this in your Lidify project root directory
# chmod +x analyze-context-bloat.sh && ./analyze-context-bloat.sh
# ==============================================================================
echo "=============================================================================="
echo "Lidify Context Bloat Analysis"
echo "=============================================================================="
echo ""
echo "Analyzing your project to find files that should be excluded from Roo Code..."
echo ""
# Check if we're in a project directory (monorepo structure)
if [ ! -f "backend/package.json" ] && [ ! -f "frontend/package.json" ] && [ ! -f "package.json" ]; then
echo "❌ Error: Run this script from your Lidify project root directory"
echo " (Looking for backend/package.json or frontend/package.json or package.json)"
exit 1
fi
echo "✅ Found project structure (monorepo detected)"
echo ""
echo "📊 TOP 30 LARGEST FILES (excluding node_modules):"
echo "=============================================================================="
find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -exec du -h {} + 2>/dev/null | sort -rh | head -30
echo ""
echo "📦 DIRECTORY SIZES (top-level):"
echo "=============================================================================="
du -h --max-depth=1 . 2>/dev/null | sort -rh
echo ""
echo "📦 SUBDIRECTORY SIZES (backend, frontend, services):"
echo "=============================================================================="
for dir in backend frontend services scripts; do
if [ -d "$dir" ]; then
echo ""
echo "--- $dir/ ---"
du -h --max-depth=2 "$dir" 2>/dev/null | sort -rh | head -10
fi
done
echo ""
echo "🖼️ IMAGE FILES (all types):"
echo "=============================================================================="
find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" -o -name "*.svg" -o -name "*.ico" \) -not -path "*/node_modules/*" 2>/dev/null | wc -l
echo "Total image files found"
echo ""
echo "Largest images:"
find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \) -not -path "*/node_modules/*" -exec du -h {} + 2>/dev/null | sort -rh | head -20
echo ""
echo "📝 LOCK FILES & GENERATED CODE:"
echo "=============================================================================="
find . -type f \( -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "*.tsbuildinfo" \) -exec du -h {} \; 2>/dev/null
echo ""
echo "📜 MIGRATION FILES:"
echo "=============================================================================="
if [ -d "backend/prisma/migrations" ]; then
echo "Total migration directory size:"
du -sh backend/prisma/migrations 2>/dev/null
echo ""
echo "Number of migrations:"
ls -1 backend/prisma/migrations 2>/dev/null | wc -l
echo ""
echo "Oldest migrations (first 10):"
ls -1 backend/prisma/migrations 2>/dev/null | head -10
echo ""
echo "Newest migrations (last 5):"
ls -1 backend/prisma/migrations 2>/dev/null | tail -5
else
echo "No migrations directory found"
fi
echo ""
echo "🗂️ FILE TYPE BREAKDOWN:"
echo "=============================================================================="
echo "TypeScript/JavaScript files:"
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) -not -path "*/node_modules/*" -not -path "*/.next/*" 2>/dev/null | wc -l
echo ""
echo "JSON files:"
find . -type f -name "*.json" -not -path "*/node_modules/*" 2>/dev/null | wc -l
echo ""
echo "CSS/Style files:"
find . -type f \( -name "*.css" -o -name "*.scss" -o -name "*.sass" \) -not -path "*/node_modules/*" 2>/dev/null | wc -l
echo ""
echo "Markdown files:"
find . -type f -name "*.md" 2>/dev/null | wc -l
echo ""
echo "💾 ESTIMATED TOKEN COUNT:"
echo "=============================================================================="
# Rough estimation: 1 token ≈ 4 characters
total_chars=$(find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.md" -o -name "*.css" -o -name "*.yml" -o -name "*.yaml" \) -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -not -path "*/dist/*" -exec cat {} \; 2>/dev/null | wc -c)
estimated_tokens=$((total_chars / 4))
if [ $estimated_tokens -gt 0 ]; then
echo "Total characters in text files: $(printf "%'d" $total_chars)"
echo "Estimated current token count: ~$(printf "%'d" $estimated_tokens) tokens"
echo ""
optimized_tokens=$((estimated_tokens * 40 / 100))
echo "Estimated AFTER .rooignore: ~$(printf "%'d" $optimized_tokens) tokens (60% reduction)"
else
echo "Could not calculate token estimate"
fi
echo ""
echo "🎯 LARGE FILES ANALYSIS:"
echo "=============================================================================="
echo "Large JSON files (>50KB):"
large_json=$(find . -type f -name "*.json" -not -path "*/node_modules/*" -not -name "package.json" -not -name "tsconfig.json" -size +50k 2>/dev/null)
if [ -n "$large_json" ]; then
echo "$large_json" | while read file; do
size=$(du -h "$file" | cut -f1)
echo " $size - $file"
done
else
echo " None found"
fi
echo ""
echo "Large CSS files (>30KB):"
large_css=$(find . -type f \( -name "*.css" -o -name "*.scss" \) -not -path "*/node_modules/*" -size +30k 2>/dev/null)
if [ -n "$large_css" ]; then
echo "$large_css" | while read file; do
size=$(du -h "$file" | cut -f1)
echo " $size - $file"
done
else
echo " None found"
fi
echo ""
echo "Test files:"
test_count=$(find . -type f \( -name "*.test.*" -o -name "*.spec.*" \) -not -path "*/node_modules/*" 2>/dev/null | wc -l)
echo " Found $test_count test files"
if [ "$test_count" -gt 0 ]; then
echo " Consider excluding with: *.test.* and *.spec.*"
fi
echo ""
python_files=$(find . -type f -name "*.py" -not -path "*/node_modules/*" 2>/dev/null | wc -l)
if [ "$python_files" -gt 0 ]; then
echo "Python files (services):"
echo " Found $python_files Python files"
echo " Largest Python files:"
find . -type f -name "*.py" -not -path "*/node_modules/*" -exec du -h {} + 2>/dev/null | sort -rh | head -5
echo ""
fi
docker_files=$(find . -maxdepth 2 -type f \( -name "Dockerfile*" -o -name "docker-compose*.yml" \) 2>/dev/null | wc -l)
if [ "$docker_files" -gt 0 ]; then
echo "Docker configuration files:"
find . -maxdepth 2 -type f \( -name "Dockerfile*" -o -name "docker-compose*.yml" \) -exec du -h {} \; 2>/dev/null
echo ""
fi
echo "=============================================================================="
echo "🎯 RECOMMENDED .rooignore ADDITIONS:"
echo "=============================================================================="
echo ""
echo "Based on this analysis, your .rooignore should definitely include:"
echo ""
echo "1. node_modules/ (if exists)"
echo "2. Lock files (package-lock.json, yarn.lock)"
echo "3. All images in assets/screenshots/"
echo "4. Build artifacts (.next/, dist/, build/)"
echo "5. Old migrations (backend/prisma/migrations/2024*/)"
echo ""
if [ -n "$large_json" ]; then
echo "6. Large JSON files:"
echo "$large_json" | while read file; do
echo " $file"
done
echo ""
fi
if [ "$test_count" -gt 5 ]; then
echo "7. Test files (*.test.*, *.spec.*)"
echo ""
fi
echo "=============================================================================="
echo "✅ Analysis complete!"
echo ""
echo "Next steps:"
echo "1. Share this output with Claude"
echo "2. Claude will create a custom .rooignore for your project"
echo "3. Copy .rooignore to project root"
echo "4. Make a Roo Code request and verify token reduction"
echo "=============================================================================="
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
# ==============================================================================
# analyze-context-bloat.sh
# ==============================================================================
# Purpose: Find large files in your project that are bloating Roo Code context
# Usage: Run this in your Lidify project root directory
# chmod +x analyze-context-bloat.sh && ./analyze-context-bloat.sh
# ==============================================================================
echo "=============================================================================="
echo "Lidify Context Bloat Analysis"
echo "=============================================================================="
echo ""
echo "Analyzing your project to find files that should be excluded from Roo Code..."
echo ""
# Check if we're in a project directory
if [ ! -f "package.json" ]; then
echo "❌ Error: Run this script from your Lidify project root directory"
exit 1
fi
echo "📊 TOP 20 LARGEST FILES (excluding node_modules):"
echo "=============================================================================="
find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -exec du -h {} + 2>/dev/null | sort -rh | head -20
echo ""
echo "📦 DIRECTORY SIZES (excluding node_modules):"
echo "=============================================================================="
du -h --max-depth=2 . 2>/dev/null | grep -v node_modules | sort -rh | head -20
echo ""
echo "🖼️ IMAGE FILES TAKING UP SPACE:"
echo "=============================================================================="
find . -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \) -not -path "*/node_modules/*" -exec du -h {} + 2>/dev/null | sort -rh | head -20
echo ""
echo "📝 LOCK FILES & GENERATED CODE:"
echo "=============================================================================="
find . -type f \( -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" -o -name "*.tsbuildinfo" \) -exec du -h {} \;
echo ""
echo "📜 MIGRATION FILES:"
echo "=============================================================================="
if [ -d "backend/prisma/migrations" ]; then
echo "Total migration directory size:"
du -sh backend/prisma/migrations
echo ""
echo "Number of migrations:"
ls -1 backend/prisma/migrations | wc -l
echo ""
echo "Oldest migrations (first 5):"
ls -1 backend/prisma/migrations | head -5
echo ""
echo "Newest migrations (last 5):"
ls -1 backend/prisma/migrations | tail -5
else
echo "No migrations directory found"
fi
echo ""
echo "💾 ESTIMATED TOKEN COUNT:"
echo "=============================================================================="
# Rough estimation: 1 token ≈ 4 characters
total_chars=$(find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -path "*/.next/*" -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.json" -o -name "*.md" 2>/dev/null | xargs cat 2>/dev/null | wc -c)
estimated_tokens=$((total_chars / 4))
echo "Estimated current token count: ~$(printf "%'d" $estimated_tokens) tokens"
echo ""
echo "🎯 RECOMMENDED .rooignore ADDITIONS:"
echo "=============================================================================="
echo "Based on this analysis, consider adding these to .rooignore:"
echo ""
# Find large JSON files
large_json=$(find . -type f -name "*.json" -not -path "*/node_modules/*" -not -name "package.json" -not -name "tsconfig.json" -size +100k -exec du -h {} \; 2>/dev/null)
if [ -n "$large_json" ]; then
echo "Large JSON files (>100KB):"
echo "$large_json"
echo ""
fi
# Find CSS/SCSS files if they're large
large_css=$(find . -type f \( -name "*.css" -o -name "*.scss" \) -not -path "*/node_modules/*" -size +50k -exec du -h {} \; 2>/dev/null)
if [ -n "$large_css" ]; then
echo "Large CSS files (>50KB):"
echo "$large_css"
echo ""
fi
# Find test files
test_files=$(find . -type f \( -name "*.test.*" -o -name "*.spec.*" \) -not -path "*/node_modules/*" | wc -l)
if [ "$test_files" -gt 0 ]; then
echo "Found $test_files test files - consider excluding with: *.test.* and *.spec.*"
echo ""
fi
echo "=============================================================================="
echo "✅ Analysis complete!"
echo ""
echo "Next steps:"
echo "1. Copy .rooignore to your project root"
echo "2. Add any large files shown above to .rooignore"
echo "3. Make a Roo Code request and check token count in OpenRouter"
echo "4. Target: 60-80K tokens (down from 177K)"
echo "=============================================================================="
+11
View File
@@ -36,6 +36,17 @@ npx prisma migrate deploy
echo "[DB] Generating Prisma client..." echo "[DB] Generating Prisma client..."
npx prisma generate npx prisma generate
# Clear Redis cache on deployment to prevent stale data (e.g., 404 images)
echo "[REDIS] Clearing cache for fresh deployment..."
node -e "
const { createClient } = require('redis');
const client = createClient({ url: process.env.REDIS_URL || 'redis://redis:6379' });
client.connect()
.then(() => client.flushAll())
.then(() => { console.log('[REDIS] Cache cleared successfully'); return client.quit(); })
.catch(err => { console.warn('[REDIS] Cache clear failed (non-critical):', err.message); });
" || echo "[REDIS] Cache clear skipped (Redis unavailable)"
# Generate session secret if not provided # Generate session secret if not provided
if [ -z "$SESSION_SECRET" ] || [ "$SESSION_SECRET" = "changeme-generate-secure-key" ]; then if [ -z "$SESSION_SECRET" ] || [ "$SESSION_SECRET" = "changeme-generate-secure-key" ]; then
echo "[WARN] SESSION_SECRET not set or using default. Generating random key..." echo "[WARN] SESSION_SECRET not set or using default. Generating random key..."
+91 -62
View File
@@ -1,12 +1,12 @@
{ {
"name": "lidify-backend", "name": "lidify-backend",
"version": "1.0.0", "version": "1.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lidify-backend", "name": "lidify-backend",
"version": "1.0.0", "version": "1.3.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@bull-board/api": "^6.14.2", "@bull-board/api": "^6.14.2",
@@ -37,6 +37,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"music-metadata": "^11.10.0", "music-metadata": "^11.10.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"p-limit": "^7.2.0",
"p-queue": "^9.0.0", "p-queue": "^9.0.0",
"podcast-index-api": "^1.1.10", "podcast-index-api": "^1.1.10",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -51,6 +52,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.17.10", "@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
@@ -105,9 +107,9 @@
} }
}, },
"node_modules/@borewit/text-codec": { "node_modules/@borewit/text-codec": {
"version": "0.2.0", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.0.tgz", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
"integrity": "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA==", "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -115,25 +117,25 @@
} }
}, },
"node_modules/@bull-board/api": { "node_modules/@bull-board/api": {
"version": "6.15.0", "version": "6.16.2",
"resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-6.16.2.tgz",
"integrity": "sha512-z8qLZ4uv83hZNu+0YnHzhVoWv1grULuYh80FdC2xXLg8M1EwsOZD9cJ5CNpgBFqHb+NVByTmf5FltIvXdOU8tQ==", "integrity": "sha512-d3kDf91FeMw/wYp8FOZJjX4hVqZEmomXtYgNRdZc0a5gTR2bmomvpwJtNBinu2lyIRFoX/Rxilz+CZ6xyw3drQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"redis-info": "^3.1.0" "redis-info": "^3.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"@bull-board/ui": "6.15.0" "@bull-board/ui": "6.16.2"
} }
}, },
"node_modules/@bull-board/express": { "node_modules/@bull-board/express": {
"version": "6.15.0", "version": "6.16.2",
"resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/express/-/express-6.16.2.tgz",
"integrity": "sha512-c/nnxr5evLNgqoSSEvTwPb+6WaTB3PN3Bq2oMTBtwCUJlZr+s1UX7gx0wVIYHjeZyUdYR7fX7hhh2cRLO5vqeg==", "integrity": "sha512-RYjWmRpixgoRVJf4/iZuwbst4EML8EnL+S2vyIn6uE0iqCXFBV63oEYJAhoEA7P50IrrktVBOU2/qTdsbih18g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "6.15.0", "@bull-board/api": "6.16.2",
"@bull-board/ui": "6.15.0", "@bull-board/ui": "6.16.2",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.2.0" "express": "^5.2.0"
} }
@@ -430,12 +432,12 @@
} }
}, },
"node_modules/@bull-board/ui": { "node_modules/@bull-board/ui": {
"version": "6.15.0", "version": "6.16.2",
"resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-6.16.2.tgz",
"integrity": "sha512-bb/j6VMq2cfPoE/ZiUO7AcYTL0IjtxvKxkYV0zu+i1pc+JEv3ct4BItCII57knJR/YjZKGmdfr079KJFvzXC5A==", "integrity": "sha512-L8ylgyJqiCrngne9GvX6zqALXnSLhzGBRaPnmO5y7Ev6K9w84EkcfhzcNw4qNH4SJAdcOm3HVf15dBU2Wznbug==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@bull-board/api": "6.15.0" "@bull-board/api": "6.16.2"
} }
}, },
"node_modules/@derhuerst/http-basic": { "node_modules/@derhuerst/http-basic": {
@@ -454,9 +456,9 @@
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.7.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -1497,9 +1499,9 @@
} }
}, },
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.4.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jsdevtools/ono": { "node_modules/@jsdevtools/ono": {
@@ -1861,6 +1863,16 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": { "node_modules/@types/express": {
"version": "4.17.25", "version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
@@ -3018,9 +3030,9 @@
} }
}, },
"node_modules/file-type": { "node_modules/file-type": {
"version": "21.1.1", "version": "21.3.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.1.tgz", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
"integrity": "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg==", "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tokenizer/inflate": "^0.4.1", "@tokenizer/inflate": "^0.4.1",
@@ -3604,12 +3616,12 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ioredis": { "node_modules/ioredis": {
"version": "5.8.2", "version": "5.9.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", "integrity": "sha512-T3VieIilNumOJCXI9SDgo4NnF6sZkd6XcmPi6qWtw4xqbt8nNz/ZVNiIH1L9puMTSHZh1mUWA4xKa2nWPF4NwQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ioredis/commands": "1.4.0", "@ioredis/commands": "1.5.0",
"cluster-key-slot": "^1.1.0", "cluster-key-slot": "^1.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"denque": "^2.1.0", "denque": "^2.1.0",
@@ -4096,9 +4108,9 @@
} }
}, },
"node_modules/music-metadata": { "node_modules/music-metadata": {
"version": "11.10.3", "version": "11.10.5",
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.3.tgz", "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.10.5.tgz",
"integrity": "sha512-j0g/x4cNNZW6I5gdcPAY+GFkJY9WHTpkFDMBJKQLxJQyvSfQbXm57fTE3haGFFuOzCgtsTd4Plwc49Sn9RacDQ==", "integrity": "sha512-G0i86zpL7AARmZx8XEkHBVf7rJMQDFfGEFc1C83//rKHGuaK0gwxmNNeo9mjm4g07KUwoT0s0dW7g5QwZhi+qQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -4111,14 +4123,14 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@borewit/text-codec": "^0.2.0", "@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0", "@tokenizer/token": "^0.3.0",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"debug": "^4.4.3", "debug": "^4.4.3",
"file-type": "^21.1.1", "file-type": "^21.2.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"strtok3": "^10.3.4", "strtok3": "^10.3.4",
"token-types": "^6.1.1", "token-types": "^6.1.2",
"uint8array-extras": "^1.5.0" "uint8array-extras": "^1.5.0"
}, },
"engines": { "engines": {
@@ -4315,15 +4327,15 @@
} }
}, },
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "2.3.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.2.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "integrity": "sha512-ATHLtwoTNDloHRFFxFJdHnG6n2WUeFjaR8XQMFdKIv0xkXjrER8/iG9iu265jOM95zXHAfv9oTkqhrfbIzosrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"p-try": "^2.0.0" "yocto-queue": "^1.2.1"
}, },
"engines": { "engines": {
"node": ">=6" "node": ">=20"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@@ -4341,10 +4353,25 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/p-locate/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-queue": { "node_modules/p-queue": {
"version": "9.0.1", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz", "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==", "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
@@ -4516,9 +4543,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -5211,12 +5238,12 @@
} }
}, },
"node_modules/token-types": { "node_modules/token-types": {
"version": "6.1.1", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
"integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@borewit/text-codec": "^0.1.0", "@borewit/text-codec": "^0.2.1",
"@tokenizer/token": "^0.3.0", "@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1" "ieee754": "^1.2.1"
}, },
@@ -5228,16 +5255,6 @@
"url": "https://github.com/sponsors/Borewit" "url": "https://github.com/sponsors/Borewit"
} }
}, },
"node_modules/token-types/node_modules/@borewit/text-codec": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz",
"integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tr46": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -5535,6 +5552,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/z-schema": { "node_modules/z-schema": {
"version": "5.0.5", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lidify-backend", "name": "lidify-backend",
"version": "1.2.0", "version": "1.3.0",
"description": "Lidify backend API server", "description": "Lidify backend API server",
"license": "GPL-3.0", "license": "GPL-3.0",
"repository": { "repository": {
@@ -46,6 +46,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"music-metadata": "^11.10.0", "music-metadata": "^11.10.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"p-limit": "^7.2.0",
"p-queue": "^9.0.0", "p-queue": "^9.0.0",
"podcast-index-api": "^1.1.10", "podcast-index-api": "^1.1.10",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -60,6 +61,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/express-session": "^1.17.10", "@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
@@ -0,0 +1,10 @@
-- Rename soulseekFallback to primaryFailureFallback (idempotent)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'SystemSettings' AND column_name = 'soulseekFallback'
) THEN
ALTER TABLE "SystemSettings" RENAME COLUMN "soulseekFallback" TO "primaryFailureFallback";
END IF;
END $$;
@@ -0,0 +1,11 @@
-- Add tokenVersion to User table (idempotent)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'User')
AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'User' AND column_name = 'tokenVersion'
) THEN
ALTER TABLE "User" ADD COLUMN "tokenVersion" INTEGER NOT NULL DEFAULT 0;
END IF;
END $$;
@@ -0,0 +1,11 @@
-- Create targetMbid index on DownloadJob (idempotent)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'DownloadJob')
AND NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'DownloadJob' AND indexname = 'DownloadJob_targetMbid_idx'
) THEN
CREATE INDEX "DownloadJob_targetMbid_idx" ON "DownloadJob"("targetMbid");
END IF;
END $$;
@@ -19,6 +19,7 @@ CREATE TABLE "User" (
"twoFactorSecret" TEXT, "twoFactorSecret" TEXT,
"twoFactorRecoveryCodes" TEXT, "twoFactorRecoveryCodes" TEXT,
"moodMixParams" JSONB, "moodMixParams" JSONB,
"tokenVersion" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id") CONSTRAINT "User_pkey" PRIMARY KEY ("id")
@@ -78,7 +79,7 @@ CREATE TABLE "SystemSettings" (
"downloadRetryAttempts" INTEGER NOT NULL DEFAULT 3, "downloadRetryAttempts" INTEGER NOT NULL DEFAULT 3,
"transcodeCacheMaxGb" INTEGER NOT NULL DEFAULT 10, "transcodeCacheMaxGb" INTEGER NOT NULL DEFAULT 10,
"downloadSource" TEXT NOT NULL DEFAULT 'soulseek', "downloadSource" TEXT NOT NULL DEFAULT 'soulseek',
"soulseekFallback" TEXT NOT NULL DEFAULT 'none', "primaryFailureFallback" TEXT NOT NULL DEFAULT 'none',
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -826,6 +827,9 @@ CREATE INDEX "DownloadJob_lidarrRef_idx" ON "DownloadJob"("lidarrRef");
-- CreateIndex -- CreateIndex
CREATE INDEX "DownloadJob_artistMbid_idx" ON "DownloadJob"("artistMbid"); CREATE INDEX "DownloadJob_artistMbid_idx" ON "DownloadJob"("artistMbid");
-- CreateIndex
CREATE INDEX "DownloadJob_targetMbid_idx" ON "DownloadJob"("targetMbid");
-- CreateIndex -- CreateIndex
CREATE INDEX "ListeningState_userId_idx" ON "ListeningState"("userId"); CREATE INDEX "ListeningState_userId_idx" ON "ListeningState"("userId");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "enrichmentConcurrency" INTEGER NOT NULL DEFAULT 1;
@@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "Album" ADD COLUMN "displayTitle" TEXT,
ADD COLUMN "displayYear" INTEGER,
ADD COLUMN "hasUserOverrides" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "userCoverUrl" TEXT,
ADD COLUMN "userGenres" JSONB;
-- AlterTable
ALTER TABLE "Artist" ADD COLUMN "displayName" TEXT,
ADD COLUMN "hasUserOverrides" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "userGenres" JSONB,
ADD COLUMN "userHeroUrl" TEXT,
ADD COLUMN "userSummary" TEXT;
-- AlterTable
ALTER TABLE "Track" ADD COLUMN "displayTitle" TEXT,
ADD COLUMN "displayTrackNo" INTEGER,
ADD COLUMN "hasUserOverrides" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "Album_hasUserOverrides_idx" ON "Album"("hasUserOverrides");
-- CreateIndex
CREATE INDEX "Artist_hasUserOverrides_idx" ON "Artist"("hasUserOverrides");
-- CreateIndex
CREATE INDEX "Track_hasUserOverrides_idx" ON "Track"("hasUserOverrides");
@@ -0,0 +1,128 @@
-- Migration: Add search vector triggers for podcasts and audiobooks
-- This migration creates PostgreSQL functions and triggers to automatically
-- populate and maintain search vectors for podcast and audiobook content
-- ============================================================================
-- PODCAST SEARCH VECTOR FUNCTION
-- ============================================================================
-- Function to generate Podcast search vector from title, author, and description
CREATE OR REPLACE FUNCTION podcast_search_vector_trigger() RETURNS trigger AS $$
BEGIN
-- Combine title, author, and description into search vector
-- Using setweight: title (A), author (B), description (C)
NEW."searchVector" :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.author, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
-- Create trigger to auto-update Podcast search vector
DROP TRIGGER IF EXISTS podcast_search_vector_update ON "Podcast";
CREATE TRIGGER podcast_search_vector_update
BEFORE INSERT OR UPDATE OF title, author, description
ON "Podcast"
FOR EACH ROW
EXECUTE FUNCTION podcast_search_vector_trigger();
-- ============================================================================
-- PODCAST EPISODE SEARCH VECTOR FUNCTION
-- ============================================================================
-- Function to generate PodcastEpisode search vector from title and description
CREATE OR REPLACE FUNCTION podcast_episode_search_vector_trigger() RETURNS trigger AS $$
BEGIN
-- Combine title and description into search vector
-- Using setweight: title (A), description (B)
NEW."searchVector" :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'B');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
-- Create trigger to auto-update PodcastEpisode search vector
DROP TRIGGER IF EXISTS podcast_episode_search_vector_update ON "PodcastEpisode";
CREATE TRIGGER podcast_episode_search_vector_update
BEFORE INSERT OR UPDATE OF title, description
ON "PodcastEpisode"
FOR EACH ROW
EXECUTE FUNCTION podcast_episode_search_vector_trigger();
-- ============================================================================
-- AUDIOBOOK SEARCH VECTOR FUNCTION
-- ============================================================================
-- Function to generate Audiobook search vector from title, author, narrator, series, and description
CREATE OR REPLACE FUNCTION audiobook_search_vector_trigger() RETURNS trigger AS $$
BEGIN
-- Combine title, author/narrator/series, and description into search vector
-- Using setweight: title (A), author/narrator/series (B), description (C)
NEW."searchVector" :=
setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(NEW.author, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.narrator, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.series, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(NEW.description, '')), 'C');
RETURN NEW;
END
$$ LANGUAGE plpgsql;
-- Create trigger to auto-update Audiobook search vector
DROP TRIGGER IF EXISTS audiobook_search_vector_update ON "Audiobook";
CREATE TRIGGER audiobook_search_vector_update
BEFORE INSERT OR UPDATE OF title, author, narrator, series, description
ON "Audiobook"
FOR EACH ROW
EXECUTE FUNCTION audiobook_search_vector_trigger();
-- ============================================================================
-- ADD SEARCH VECTOR COLUMNS
-- ============================================================================
-- Add searchVector column to Podcast table
ALTER TABLE "Podcast" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
-- Add searchVector column to PodcastEpisode table
ALTER TABLE "PodcastEpisode" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
-- Add searchVector column to Audiobook table
ALTER TABLE "Audiobook" ADD COLUMN IF NOT EXISTS "searchVector" tsvector;
-- ============================================================================
-- CREATE GIN INDEXES
-- ============================================================================
-- Create GIN index on Podcast search vector
CREATE INDEX IF NOT EXISTS "Podcast_searchVector_idx" ON "Podcast" USING GIN ("searchVector");
-- Create GIN index on PodcastEpisode search vector
CREATE INDEX IF NOT EXISTS "PodcastEpisode_searchVector_idx" ON "PodcastEpisode" USING GIN ("searchVector");
-- Create GIN index on Audiobook search vector
CREATE INDEX IF NOT EXISTS "Audiobook_searchVector_idx" ON "Audiobook" USING GIN ("searchVector");
-- ============================================================================
-- POPULATE EXISTING RECORDS
-- ============================================================================
-- Update all existing Podcasts to populate their search vectors
UPDATE "Podcast"
SET "searchVector" =
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(author, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'C');
-- Update all existing PodcastEpisodes to populate their search vectors
UPDATE "PodcastEpisode"
SET "searchVector" =
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'B');
-- Update all existing Audiobooks to populate their search vectors
UPDATE "Audiobook"
SET "searchVector" =
setweight(to_tsvector('english', COALESCE(title, '')), 'A') ||
setweight(to_tsvector('english', COALESCE(author, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(narrator, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(series, '')), 'B') ||
setweight(to_tsvector('english', COALESCE(description, '')), 'C');
@@ -0,0 +1,32 @@
-- CreateTable
CREATE TABLE "EnrichmentFailure" (
"id" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"entityName" TEXT,
"errorMessage" TEXT,
"errorCode" TEXT,
"retryCount" INTEGER NOT NULL DEFAULT 0,
"maxRetries" INTEGER NOT NULL DEFAULT 3,
"firstFailedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastFailedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"skipped" BOOLEAN NOT NULL DEFAULT false,
"skippedAt" TIMESTAMP(3),
"resolved" BOOLEAN NOT NULL DEFAULT false,
"resolvedAt" TIMESTAMP(3),
"metadata" JSONB,
CONSTRAINT "EnrichmentFailure_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EnrichmentFailure_entityType_resolved_idx" ON "EnrichmentFailure"("entityType", "resolved");
-- CreateIndex
CREATE INDEX "EnrichmentFailure_skipped_idx" ON "EnrichmentFailure"("skipped");
-- CreateIndex
CREATE INDEX "EnrichmentFailure_lastFailedAt_idx" ON "EnrichmentFailure"("lastFailedAt");
-- CreateIndex
CREATE UNIQUE INDEX "EnrichmentFailure_entityType_entityId_key" ON "EnrichmentFailure"("entityType", "entityId");
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Album" ADD COLUMN "originalYear" INTEGER;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "lidarrWebhookSecret" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Track" ADD COLUMN "analysisStartedAt" TIMESTAMP(3);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "audioAnalyzerWorkers" INTEGER NOT NULL DEFAULT 2;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "lastfmApiKey" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN "soulseekConcurrentDownloads" INTEGER NOT NULL DEFAULT 4;
@@ -0,0 +1,21 @@
-- Add downloadSource column if it doesn't exist (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'SystemSettings' AND column_name = 'downloadSource'
) THEN
ALTER TABLE "SystemSettings" ADD COLUMN "downloadSource" TEXT NOT NULL DEFAULT 'soulseek';
END IF;
END $$;
-- Add primaryFailureFallback column if it doesn't exist (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'SystemSettings' AND column_name = 'primaryFailureFallback'
) THEN
ALTER TABLE "SystemSettings" ADD COLUMN "primaryFailureFallback" TEXT NOT NULL DEFAULT 'none';
END IF;
END $$;
+105 -37
View File
@@ -18,6 +18,7 @@ model User {
twoFactorSecret String? // TOTP secret (encrypted) twoFactorSecret String? // TOTP secret (encrypted)
twoFactorRecoveryCodes String? // Recovery codes (encrypted, comma-separated hashed codes) twoFactorRecoveryCodes String? // Recovery codes (encrypted, comma-separated hashed codes)
moodMixParams Json? // Saved mood mix parameters for "Your Mood Mix" moodMixParams Json? // Saved mood mix parameters for "Your Mood Mix"
tokenVersion Int @default(0) // Incremented on password change to invalidate tokens
createdAt DateTime @default(now()) createdAt DateTime @default(now())
plays Play[] plays Play[]
@@ -77,9 +78,10 @@ model SystemSettings {
// === Download Services === // === Download Services ===
// Lidarr // Lidarr
lidarrEnabled Boolean @default(true) lidarrEnabled Boolean @default(true)
lidarrUrl String? @default("http://localhost:8686") lidarrUrl String? @default("http://localhost:8686")
lidarrApiKey String? // Encrypted lidarrApiKey String? // Encrypted
lidarrWebhookSecret String? // Encrypted - Shared secret for webhook verification
// === AI Services === // === AI Services ===
// OpenAI (for future AI features) // OpenAI (for future AI features)
@@ -92,6 +94,9 @@ model SystemSettings {
fanartEnabled Boolean @default(false) fanartEnabled Boolean @default(false)
fanartApiKey String? // Encrypted fanartApiKey String? // Encrypted
// Last.fm (optional user override - app ships with default key)
lastfmApiKey String? // Encrypted
// === Media Services === // === Media Services ===
// Audiobookshelf // Audiobookshelf
audiobookshelfEnabled Boolean @default(false) audiobookshelfEnabled Boolean @default(false)
@@ -118,12 +123,15 @@ model SystemSettings {
maxConcurrentDownloads Int @default(3) maxConcurrentDownloads Int @default(3)
downloadRetryAttempts Int @default(3) downloadRetryAttempts Int @default(3)
transcodeCacheMaxGb Int @default(10) // Transcode cache size limit in GB transcodeCacheMaxGb Int @default(10) // Transcode cache size limit in GB
enrichmentConcurrency Int @default(1) // 1-5, number of parallel enrichment workers
audioAnalyzerWorkers Int @default(2) // 1-8, number of parallel audio analysis workers
soulseekConcurrentDownloads Int @default(4) // 1-10, concurrent Soulseek downloads
// === Download Preferences === // === Download Preferences ===
// Primary download source: "soulseek" (per-track) or "lidarr" (full albums) // Primary download source: "soulseek" (per-track) or "lidarr" (full albums)
downloadSource String @default("soulseek") downloadSource String @default("soulseek")
// When soulseek is primary and fails: "none" (skip) or "lidarr" (download full album) // Fallback when primary source fails: "none" (skip), "lidarr" (full album), or "soulseek" (track-based)
soulseekFallback String @default("none") primaryFailureFallback String @default("none")
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -143,6 +151,13 @@ model Artist {
enrichmentStatus String @default("pending") // pending, enriching, completed, failed enrichmentStatus String @default("pending") // pending, enriching, completed, failed
searchVector Unsupported("tsvector")? searchVector Unsupported("tsvector")?
// User overrides (optional, takes display precedence)
displayName String? // User-provided display name
userSummary String? @db.Text // User-provided bio
userHeroUrl String? // User-uploaded/linked image
userGenres Json? // User-modified genres (array of strings)
hasUserOverrides Boolean @default(false) // Quick check flag
albums Album[] albums Album[]
similarFrom SimilarArtist[] @relation("FromArtist") similarFrom SimilarArtist[] @relation("FromArtist")
similarTo SimilarArtist[] @relation("ToArtist") similarTo SimilarArtist[] @relation("ToArtist")
@@ -151,6 +166,7 @@ model Artist {
@@index([name]) @@index([name])
@@index([normalizedName]) @@index([normalizedName])
@@index([searchVector], type: Gin) @@index([searchVector], type: Gin)
@@index([hasUserOverrides])
} }
model Album { model Album {
@@ -158,7 +174,8 @@ model Album {
rgMbid String @unique // release group MBID rgMbid String @unique // release group MBID
artistId String artistId String
title String title String
year Int? year Int? // File metadata date (may be remaster)
originalYear Int? // Original release date from MusicBrainz
coverUrl String? coverUrl String?
primaryType String // Album, EP, Single, Live, Compilation primaryType String // Album, EP, Single, Live, Compilation
label String? // Record label (from MusicBrainz) label String? // Record label (from MusicBrainz)
@@ -167,6 +184,13 @@ model Album {
location AlbumLocation @default(LIBRARY) // LIBRARY or DISCOVER location AlbumLocation @default(LIBRARY) // LIBRARY or DISCOVER
searchVector Unsupported("tsvector")? searchVector Unsupported("tsvector")?
// User overrides (optional, takes display precedence)
displayTitle String? // User-provided display title
displayYear Int? // User-provided year
userCoverUrl String? // User-uploaded/linked cover
userGenres Json? // User-modified genres (array of strings)
hasUserOverrides Boolean @default(false) // Quick check flag
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade) artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
tracks Track[] tracks Track[]
@@ -174,6 +198,7 @@ model Album {
@@index([location]) @@index([location])
@@index([title]) @@index([title])
@@index([searchVector], type: Gin) @@index([searchVector], type: Gin)
@@index([hasUserOverrides])
} }
model Track { model Track {
@@ -190,6 +215,11 @@ model Track {
fileModified DateTime // mtime for change detection fileModified DateTime // mtime for change detection
fileSize Int // File size in bytes fileSize Int // File size in bytes
// User overrides (optional, takes display precedence)
displayTitle String? // User-provided display title
displayTrackNo Int? // User-provided track number
hasUserOverrides Boolean @default(false) // Quick check flag
// === Audio Analysis (Essentia) === // === Audio Analysis (Essentia) ===
// Rhythm // Rhythm
bpm Float? // Beats per minute (e.g., 120.5) bpm Float? // Beats per minute (e.g., 120.5)
@@ -235,13 +265,14 @@ model Track {
lastfmTags String[] // ["chill", "workout", "sad", "90s"] lastfmTags String[] // ["chill", "workout", "sad", "90s"]
// Analysis Metadata // Analysis Metadata
analysisStatus String @default("pending") // pending, processing, completed, failed analysisStatus String @default("pending") // pending, processing, completed, failed
analysisVersion String? // Essentia version used analysisStartedAt DateTime? // When processing began (for timeout detection)
analysisMode String? // 'standard' or 'enhanced' analysisVersion String? // Essentia version used
analyzedAt DateTime? analysisMode String? // 'standard' or 'enhanced'
analysisError String? // Error message if failed analyzedAt DateTime?
analysisRetryCount Int @default(0) // Number of retry attempts analysisError String? // Error message if failed
updatedAt DateTime @updatedAt analysisRetryCount Int @default(0) // Number of retry attempts
updatedAt DateTime @updatedAt
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade) album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
plays Play[] plays Play[]
@@ -272,6 +303,7 @@ model Track {
@@index([arousal]) @@index([arousal])
@@index([acousticness]) @@index([acousticness])
@@index([instrumentalness]) @@index([instrumentalness])
@@index([hasUserOverrides])
} }
// Transcoded file cache for audio streaming // Transcoded file cache for audio streaming
@@ -479,6 +511,7 @@ model DownloadJob {
@@index([startedAt]) @@index([startedAt])
@@index([lidarrRef]) @@index([lidarrRef])
@@index([artistMbid]) @@index([artistMbid])
@@index([targetMbid])
} }
model ListeningState { model ListeningState {
@@ -640,6 +673,9 @@ model Audiobook {
audioUrl String // Audiobookshelf streaming URL audioUrl String // Audiobookshelf streaming URL
libraryId String? // Audiobookshelf library ID libraryId String? // Audiobookshelf library ID
// Full-text search
searchVector Unsupported("tsvector")?
// Timestamps // Timestamps
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -649,6 +685,7 @@ model Audiobook {
@@index([author]) @@index([author])
@@index([series]) @@index([series])
@@index([lastSyncedAt]) @@index([lastSyncedAt])
@@index([searchVector], type: Gin)
} }
model PodcastRecommendation { model PodcastRecommendation {
@@ -676,46 +713,49 @@ model PodcastRecommendation {
// ============================================ // ============================================
model Podcast { model Podcast {
id String @id @default(cuid()) id String @id @default(cuid())
feedUrl String @unique feedUrl String @unique
title String title String
author String? author String?
description String? @db.Text description String? @db.Text
imageUrl String? // Original feed image URL imageUrl String? // Original feed image URL
localCoverPath String? // Local cached cover image path localCoverPath String? // Local cached cover image path
itunesId String? @unique itunesId String? @unique
language String? language String?
explicit Boolean @default(false) explicit Boolean @default(false)
episodeCount Int @default(0) episodeCount Int @default(0)
lastRefreshed DateTime @default(now()) lastRefreshed DateTime @default(now())
refreshInterval Int @default(3600) // seconds (1 hour default) refreshInterval Int @default(3600) // seconds (1 hour default)
autoRefresh Boolean @default(true) autoRefresh Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
searchVector Unsupported("tsvector")?
episodes PodcastEpisode[] episodes PodcastEpisode[]
subscriptions PodcastSubscription[] subscriptions PodcastSubscription[]
@@index([itunesId]) @@index([itunesId])
@@index([lastRefreshed]) @@index([lastRefreshed])
@@index([searchVector], type: Gin)
} }
model PodcastEpisode { model PodcastEpisode {
id String @id @default(cuid()) id String @id @default(cuid())
podcastId String podcastId String
guid String // RSS GUID (unique per feed) guid String // RSS GUID (unique per feed)
title String title String
description String? @db.Text description String? @db.Text
audioUrl String // Direct MP3/audio URL from RSS audioUrl String // Direct MP3/audio URL from RSS
duration Int @default(0) // seconds duration Int @default(0) // seconds
publishedAt DateTime publishedAt DateTime
episodeNumber Int? episodeNumber Int?
season Int? season Int?
imageUrl String? // Episode-specific image URL imageUrl String? // Episode-specific image URL
localCoverPath String? // Local cached episode cover localCoverPath String? // Local cached episode cover
fileSize Int? // bytes fileSize Int? // bytes
mimeType String? @default("audio/mpeg") mimeType String? @default("audio/mpeg")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
searchVector Unsupported("tsvector")?
podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade) podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
progress PodcastProgress[] progress PodcastProgress[]
@@ -723,6 +763,7 @@ model PodcastEpisode {
@@unique([podcastId, guid]) @@unique([podcastId, guid])
@@index([podcastId, publishedAt]) @@index([podcastId, publishedAt])
@@index([searchVector], type: Gin)
} }
// User podcast subscriptions // User podcast subscriptions
@@ -976,3 +1017,30 @@ model Notification {
@@index([userId, read]) @@index([userId, read])
@@index([createdAt]) @@index([createdAt])
} }
// ============================================
// Enrichment Failure Tracking
// ============================================
model EnrichmentFailure {
id String @id @default(cuid())
entityType String // artist, track, audio
entityId String // Artist/Track ID
entityName String? // Display name
errorMessage String? // Human-readable error
errorCode String? // Machine-readable code
retryCount Int @default(0)
maxRetries Int @default(3)
firstFailedAt DateTime @default(now())
lastFailedAt DateTime @default(now())
skipped Boolean @default(false)
skippedAt DateTime?
resolved Boolean @default(false)
resolvedAt DateTime?
metadata Json? // Additional context (filePath, etc.)
@@unique([entityType, entityId])
@@index([entityType, resolved])
@@index([skipped])
@@index([lastFailedAt])
}
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env ts-node
/**
* Backfill Script: Populate originalYear for existing albums
*
* This script populates the new originalYear field for albums that don't have it yet.
*
* Strategy:
* 1. For albums already enriched with MusicBrainz data, copy year to originalYear
* (since enrichment overwrites year with the original release date)
* 2. Skip temporary albums (temp-* MBIDs)
*
* Usage:
* npx ts-node scripts/backfill-original-year.ts [--dry-run]
*
* Options:
* --dry-run Show what would be updated without making changes
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function backfillOriginalYear(dryRun: boolean = false) {
console.log("=== Backfill originalYear Script ===\n");
console.log(
`Mode: ${
dryRun ? "DRY RUN (no changes)" : "LIVE (will update database)"
}\n`
);
try {
// Find albums that need backfilling
const albumsToBackfill = await prisma.album.findMany({
where: {
originalYear: null,
year: { not: null }, // Only albums that have a year value
rgMbid: { not: { startsWith: "temp-" } }, // Skip temporary albums
},
select: {
id: true,
rgMbid: true,
title: true,
year: true,
originalYear: true,
artist: {
select: {
name: true,
},
},
},
});
console.log(`Found ${albumsToBackfill.length} albums to backfill\n`);
if (albumsToBackfill.length === 0) {
console.log("✓ No albums need backfilling. All done!");
return;
}
// Show sample of albums to be updated
console.log("Sample of albums to be updated:");
albumsToBackfill.slice(0, 5).forEach((album, idx) => {
console.log(
` ${idx + 1}. "${album.title}" by ${album.artist.name}`
);
console.log(
` Current: year=${album.year}, originalYear=${album.originalYear}`
);
console.log(` Will set: originalYear=${album.year}\n`);
});
if (albumsToBackfill.length > 5) {
console.log(
` ... and ${albumsToBackfill.length - 5} more albums\n`
);
}
if (dryRun) {
console.log(
"DRY RUN: No changes made. Remove --dry-run to apply updates."
);
return;
}
// Confirm before proceeding in live mode
console.log(
`Proceeding with backfill of ${albumsToBackfill.length} albums...\n`
);
// Process in batches to avoid overwhelming the database
const BATCH_SIZE = 100;
let processed = 0;
let updated = 0;
for (let i = 0; i < albumsToBackfill.length; i += BATCH_SIZE) {
const batch = albumsToBackfill.slice(i, i + BATCH_SIZE);
// Update each album in the batch
const updatePromises = batch.map((album) =>
prisma.album.update({
where: { id: album.id },
data: { originalYear: album.year },
})
);
await Promise.all(updatePromises);
processed += batch.length;
updated += batch.length;
const progress = (
(processed / albumsToBackfill.length) *
100
).toFixed(1);
console.log(
`Progress: ${processed}/${albumsToBackfill.length} (${progress}%) albums updated`
);
}
console.log(`\n✓ Backfill complete!`);
console.log(` - Total albums updated: ${updated}`);
console.log(` - Field populated: originalYear`);
console.log(
`\nNote: Future albums will have originalYear populated automatically during enrichment.`
);
} catch (error) {
console.error("\n✗ Error during backfill:", error);
throw error;
} finally {
await prisma.$disconnect();
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
// Run the backfill
backfillOriginalYear(dryRun)
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
process.exit(1);
});
+11 -11
View File
@@ -1,6 +1,8 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import { z } from "zod"; import { z } from "zod";
import * as fs from "fs";
import { validateMusicConfig, MusicConfig } from "./utils/configValidator"; import { validateMusicConfig, MusicConfig } from "./utils/configValidator";
import { logger } from "./utils/logger";
dotenv.config(); dotenv.config();
@@ -18,14 +20,14 @@ const envSchema = z.object({
try { try {
envSchema.parse(process.env); envSchema.parse(process.env);
console.log("Environment variables validated"); logger.debug("Environment variables validated");
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
console.error(" Environment validation failed:"); logger.error(" Environment validation failed:");
error.errors.forEach((err) => { error.errors.forEach((err) => {
console.error(` - ${err.path.join(".")}: ${err.message}`); logger.error(` - ${err.path.join(".")}: ${err.message}`);
}); });
console.error( logger.error(
"\n Please check your .env file and ensure all required variables are set." "\n Please check your .env file and ensure all required variables are set."
); );
process.exit(1); process.exit(1);
@@ -47,10 +49,10 @@ let musicConfig: MusicConfig = {
export async function initializeMusicConfig() { export async function initializeMusicConfig() {
try { try {
musicConfig = await validateMusicConfig(); musicConfig = await validateMusicConfig();
console.log("Music configuration initialized"); logger.debug("Music configuration initialized");
} catch (err: any) { } catch (err: any) {
console.error(" Configuration validation failed:", err.message); logger.error(" Configuration validation failed:", err.message);
console.warn(" Using default/environment configuration"); logger.warn(" Using default/environment configuration");
// Don't exit process - allow app to start for other features // Don't exit process - allow app to start for other features
// Music features will fail gracefully if config is invalid // Music features will fail gracefully if config is invalid
} }
@@ -80,11 +82,9 @@ export const config = {
} }
: undefined, : undefined,
// Last.fm - ships with default app key, users can override in settings // Last.fm - ships with default app key, user can optionally override
lastfm: { lastfm: {
// Default application API key (free tier, for public use) apiKey: process.env.LASTFM_API_KEY || "95fe0eaa9875db7bb8539b2c738b4dcd",
// Users can override this in System Settings with their own key
apiKey: process.env.LASTFM_API_KEY || "c1797de6bf0b7e401b623118120cd9e1",
}, },
// OpenAI - reads from database // OpenAI - reads from database
+117 -20
View File
@@ -6,6 +6,7 @@ import helmet from "helmet";
import { config } from "./config"; import { config } from "./config";
import { redisClient } from "./utils/redis"; import { redisClient } from "./utils/redis";
import { prisma } from "./utils/db"; import { prisma } from "./utils/db";
import { logger } from "./utils/logger";
import authRoutes from "./routes/auth"; import authRoutes from "./routes/auth";
import onboardingRoutes from "./routes/onboarding"; import onboardingRoutes from "./routes/onboarding";
@@ -38,6 +39,7 @@ import analysisRoutes from "./routes/analysis";
import releasesRoutes from "./routes/releases"; import releasesRoutes from "./routes/releases";
import { dataCacheService } from "./services/dataCache"; import { dataCacheService } from "./services/dataCache";
import { errorHandler } from "./middleware/errorHandler"; import { errorHandler } from "./middleware/errorHandler";
import { requireAuth, requireAdmin } from "./middleware/auth";
import { import {
authLimiter, authLimiter,
apiLimiter, apiLimiter,
@@ -80,7 +82,7 @@ app.use(
} else { } else {
// For self-hosted: allow anyway but log it // For self-hosted: allow anyway but log it
// Users shouldn't have to configure CORS for their own app // Users shouldn't have to configure CORS for their own app
console.log( logger.debug(
`[CORS] Origin ${origin} not in allowlist, allowing anyway (self-hosted)` `[CORS] Origin ${origin} not in allowlist, allowing anyway (self-hosted)`
); );
callback(null, true); callback(null, true);
@@ -111,10 +113,8 @@ app.use(
proxy: true, // Trust the reverse proxy proxy: true, // Trust the reverse proxy
cookie: { cookie: {
httpOnly: true, httpOnly: true,
// For self-hosted apps: allow HTTP access (common for LAN deployments) secure: process.env.NODE_ENV === "production",
// If behind HTTPS reverse proxy, the proxy should handle security sameSite: "strict",
secure: false,
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
}, },
}) })
@@ -167,8 +167,15 @@ app.get("/api/health", (req, res) => {
}); });
// Swagger API Documentation // Swagger API Documentation
// In production: require auth unless DOCS_PUBLIC=true
// In development: always public for easier testing
const docsMiddleware = config.nodeEnv === "production" && process.env.DOCS_PUBLIC !== "true"
? [requireAuth]
: [];
app.use( app.use(
"/api/docs", "/api/docs",
...docsMiddleware,
swaggerUi.serve, swaggerUi.serve,
swaggerUi.setup(swaggerSpec, { swaggerUi.setup(swaggerSpec, {
customCss: ".swagger-ui .topbar { display: none }", customCss: ".swagger-ui .topbar { display: none }",
@@ -177,15 +184,60 @@ app.use(
); );
// Serve raw OpenAPI spec // Serve raw OpenAPI spec
app.get("/api/docs.json", (req, res) => { app.get("/api/docs.json", ...docsMiddleware, (req, res) => {
res.json(swaggerSpec); res.json(swaggerSpec);
}); });
// Error handler // Error handler
app.use(errorHandler); app.use(errorHandler);
// Health check functions
async function checkPostgresConnection() {
try {
await prisma.$queryRaw`SELECT 1`;
logger.debug("✓ PostgreSQL connection verified");
} catch (error) {
logger.error("✗ PostgreSQL connection failed:", {
error: error instanceof Error ? error.message : String(error),
databaseUrl: config.databaseUrl?.replace(/:[^:@]+@/, ':***@') // Hide password
});
logger.error("Unable to connect to PostgreSQL. Please ensure:");
logger.error(" 1. PostgreSQL is running on the correct port (default: 5433)");
logger.error(" 2. DATABASE_URL in .env is correct");
logger.error(" 3. Database credentials are valid");
process.exit(1);
}
}
async function checkRedisConnection() {
try {
// Check if Redis client is actually connected
// The redis client has automatic reconnection, so we need to check status first
if (!redisClient.isReady) {
throw new Error("Redis client is not ready - connection failed or still connecting");
}
// If connected, verify with ping
await redisClient.ping();
logger.debug("✓ Redis connection verified");
} catch (error) {
logger.error("✗ Redis connection failed:", {
error: error instanceof Error ? error.message : String(error),
redisUrl: config.redisUrl?.replace(/:[^:@]+@/, ':***@') // Hide password if any
});
logger.error("Unable to connect to Redis. Please ensure:");
logger.error(" 1. Redis is running on the correct port (default: 6380)");
logger.error(" 2. REDIS_URL in .env is correct");
process.exit(1);
}
}
app.listen(config.port, "0.0.0.0", async () => { app.listen(config.port, "0.0.0.0", async () => {
console.log( // Verify database connections before proceeding
await checkPostgresConnection();
await checkRedisConnection();
logger.debug(
`Lidify API running on port ${config.port} (accessible on all network interfaces)` `Lidify API running on port ${config.port} (accessible on all network interfaces)`
); );
@@ -224,8 +276,8 @@ app.listen(config.port, "0.0.0.0", async () => {
serverAdapter, serverAdapter,
}); });
app.use("/api/admin/queues", serverAdapter.getRouter()); app.use("/api/admin/queues", requireAuth, requireAdmin, serverAdapter.getRouter());
console.log("Bull Board dashboard available at /api/admin/queues"); logger.debug("Bull Board dashboard available at /api/admin/queues (admin-only)");
// Note: Native library scanning is now triggered manually via POST /library/scan // Note: Native library scanning is now triggered manually via POST /library/scan
// No automatic sync on startup - user must manually scan their music folder // No automatic sync on startup - user must manually scan their music folder
@@ -233,7 +285,7 @@ app.listen(config.port, "0.0.0.0", async () => {
// Enrichment worker enabled for OWNED content only // Enrichment worker enabled for OWNED content only
// - Background enrichment: Genres, MBIDs, similar artists for owned albums/artists // - Background enrichment: Genres, MBIDs, similar artists for owned albums/artists
// - On-demand fetching: Artist images, bios when browsing (cached in Redis 7 days) // - On-demand fetching: Artist images, bios when browsing (cached in Redis 7 days)
console.log( logger.debug(
"Background enrichment enabled for owned content (genres, MBIDs, etc.)" "Background enrichment enabled for owned content (genres, MBIDs, etc.)"
); );
@@ -241,7 +293,7 @@ app.listen(config.port, "0.0.0.0", async () => {
// This populates Redis with existing artist images and album covers // This populates Redis with existing artist images and album covers
// so first page loads are instant instead of waiting for cache population // so first page loads are instant instead of waiting for cache population
dataCacheService.warmupCache().catch((err) => { dataCacheService.warmupCache().catch((err) => {
console.error("Cache warmup failed:", err); logger.error("Cache warmup failed:", err);
}); });
// Podcast cache cleanup - runs daily to remove cached episodes older than 30 days // Podcast cache cleanup - runs daily to remove cached episodes older than 30 days
@@ -249,17 +301,62 @@ app.listen(config.port, "0.0.0.0", async () => {
// Run cleanup on startup (async, don't block) // Run cleanup on startup (async, don't block)
cleanupExpiredCache().catch((err) => { cleanupExpiredCache().catch((err) => {
console.error("Podcast cache cleanup failed:", err); logger.error("Podcast cache cleanup failed:", err);
}); });
// Schedule daily cleanup (every 24 hours) // Schedule daily cleanup (every 24 hours)
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000; const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
setInterval(() => { setInterval(() => {
cleanupExpiredCache().catch((err) => { cleanupExpiredCache().catch((err) => {
console.error("Scheduled podcast cache cleanup failed:", err); logger.error("Scheduled podcast cache cleanup failed:", err);
}); });
}, TWENTY_FOUR_HOURS); }, TWENTY_FOUR_HOURS);
console.log("Podcast cache cleanup scheduled (daily, 30-day expiry)"); logger.debug("Podcast cache cleanup scheduled (daily, 30-day expiry)");
// Auto-sync audiobooks on startup if cache is empty
// This prevents "disappeared" audiobooks after container rebuilds
(async () => {
try {
const { getSystemSettings } = await import("./utils/systemSettings");
const settings = await getSystemSettings();
// Only proceed if Audiobookshelf is configured and enabled
if (settings?.audiobookshelfEnabled && settings?.audiobookshelfUrl) {
// Check if cache is empty
const cachedCount = await prisma.audiobook.count();
if (cachedCount === 0) {
logger.debug(
"[STARTUP] Audiobook cache is empty - auto-syncing from Audiobookshelf..."
);
const { audiobookCacheService } = await import(
"./services/audiobookCache"
);
const result = await audiobookCacheService.syncAll();
logger.debug(
`[STARTUP] Audiobook auto-sync complete: ${result.synced} audiobooks cached`
);
} else {
logger.debug(
`[STARTUP] Audiobook cache has ${cachedCount} entries - skipping auto-sync`
);
}
}
} catch (err) {
logger.error("[STARTUP] Audiobook auto-sync failed:", err);
// Non-fatal - user can manually sync later
}
})();
// Reconcile download queue state with database
const { downloadQueueManager } = await import("./services/downloadQueue");
try {
const result = await downloadQueueManager.reconcileOnStartup();
logger.debug(`Download queue reconciled: ${result.loaded} active, ${result.failed} marked failed`);
} catch (err) {
logger.error("Download queue reconciliation failed:", err);
// Non-fatal - queue will start fresh
}
}); });
// Graceful shutdown handling // Graceful shutdown handling
@@ -267,12 +364,12 @@ let isShuttingDown = false;
async function gracefulShutdown(signal: string) { async function gracefulShutdown(signal: string) {
if (isShuttingDown) { if (isShuttingDown) {
console.log("Shutdown already in progress..."); logger.debug("Shutdown already in progress...");
return; return;
} }
isShuttingDown = true; isShuttingDown = true;
console.log(`\nReceived ${signal}. Starting graceful shutdown...`); logger.debug(`\nReceived ${signal}. Starting graceful shutdown...`);
try { try {
// Shutdown workers (intervals, crons, queues) // Shutdown workers (intervals, crons, queues)
@@ -280,17 +377,17 @@ async function gracefulShutdown(signal: string) {
await shutdownWorkers(); await shutdownWorkers();
// Close Redis connection // Close Redis connection
console.log("Closing Redis connection..."); logger.debug("Closing Redis connection...");
await redisClient.quit(); await redisClient.quit();
// Close Prisma connection // Close Prisma connection
console.log("Closing database connection..."); logger.debug("Closing database connection...");
await prisma.$disconnect(); await prisma.$disconnect();
console.log("Graceful shutdown complete"); logger.debug("Graceful shutdown complete");
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error("Error during shutdown:", error); logger.error("Error during shutdown:", error);
process.exit(1); process.exit(1);
} }
} }
+231 -24
View File
@@ -1,4 +1,5 @@
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import { getSystemSettings } from "../utils/systemSettings"; import { getSystemSettings } from "../utils/systemSettings";
import { import {
cleanStuckDownloads, cleanStuckDownloads,
@@ -14,19 +15,45 @@ class QueueCleanerService {
private maxEmptyChecks = 3; // Stop after 3 consecutive empty checks private maxEmptyChecks = 3; // Stop after 3 consecutive empty checks
private timeoutId?: NodeJS.Timeout; private timeoutId?: NodeJS.Timeout;
// Cached dynamic imports (lazy-loaded once, reused on subsequent calls)
private discoverWeeklyService: typeof import("../services/discoverWeekly")["discoverWeeklyService"] | null = null;
private matchAlbum: typeof import("../utils/fuzzyMatch")["matchAlbum"] | null = null;
/**
* Get discoverWeeklyService (lazy-loaded and cached)
*/
private async getDiscoverWeeklyService() {
if (!this.discoverWeeklyService) {
const module = await import("../services/discoverWeekly");
this.discoverWeeklyService = module.discoverWeeklyService;
}
return this.discoverWeeklyService;
}
/**
* Get matchAlbum function (lazy-loaded and cached)
*/
private async getMatchAlbum() {
if (!this.matchAlbum) {
const module = await import("../utils/fuzzyMatch");
this.matchAlbum = module.matchAlbum;
}
return this.matchAlbum;
}
/** /**
* Start the polling loop * Start the polling loop
* Safe to call multiple times - won't create duplicate loops * Safe to call multiple times - won't create duplicate loops
*/ */
async start() { async start() {
if (this.isRunning) { if (this.isRunning) {
console.log(" Queue cleaner already running"); logger.debug(" Queue cleaner already running");
return; return;
} }
this.isRunning = true; this.isRunning = true;
this.emptyQueueChecks = 0; this.emptyQueueChecks = 0;
console.log(" Queue cleaner started (checking every 30s)"); logger.debug(" Queue cleaner started (checking every 30s)");
await this.runCleanup(); await this.runCleanup();
} }
@@ -40,7 +67,7 @@ class QueueCleanerService {
this.timeoutId = undefined; this.timeoutId = undefined;
} }
this.isRunning = false; this.isRunning = false;
console.log(" Queue cleaner stopped (queue empty)"); logger.debug(" Queue cleaner stopped (queue empty)");
} }
/** /**
@@ -54,7 +81,7 @@ class QueueCleanerService {
const settings = await getSystemSettings(); const settings = await getSystemSettings();
if (!settings?.lidarrUrl || !settings?.lidarrApiKey) { if (!settings?.lidarrUrl || !settings?.lidarrApiKey) {
console.log(" Lidarr not configured, stopping queue cleaner"); logger.debug(" Lidarr not configured, stopping queue cleaner");
this.stop(); this.stop();
return; return;
} }
@@ -63,7 +90,7 @@ class QueueCleanerService {
const staleCount = const staleCount =
await simpleDownloadManager.markStaleJobsAsFailed(); await simpleDownloadManager.markStaleJobsAsFailed();
if (staleCount > 0) { if (staleCount > 0) {
console.log(`⏰ Cleaned up ${staleCount} stale download(s)`); logger.debug(`⏰ Cleaned up ${staleCount} stale download(s)`);
this.emptyQueueChecks = 0; // Reset counter this.emptyQueueChecks = 0; // Reset counter
} }
@@ -71,20 +98,37 @@ class QueueCleanerService {
const reconcileResult = const reconcileResult =
await simpleDownloadManager.reconcileWithLidarr(); await simpleDownloadManager.reconcileWithLidarr();
if (reconcileResult.reconciled > 0) { if (reconcileResult.reconciled > 0) {
console.log( logger.debug(
`✓ Reconciled ${reconcileResult.reconciled} job(s) with Lidarr` `✓ Reconciled ${reconcileResult.reconciled} job(s) with Lidarr`
); );
this.emptyQueueChecks = 0; // Reset counter this.emptyQueueChecks = 0; // Reset counter
} }
// PART 0.26: Sync with Lidarr queue (detect cancelled downloads)
const queueSyncResult = await simpleDownloadManager.syncWithLidarrQueue();
if (queueSyncResult.cancelled > 0) {
logger.debug(
`✓ Synced ${queueSyncResult.cancelled} job(s) with Lidarr queue (cancelled/completed)`
);
this.emptyQueueChecks = 0; // Reset counter
}
// PART 0.3: Reconcile processing jobs with local library (critical fix for #31)
// Check if albums already exist in Lidify's database even if Lidarr webhooks were missed
const localReconcileResult = await this.reconcileWithLocalLibrary();
if (localReconcileResult.reconciled > 0) {
logger.debug(
`✓ Reconciled ${localReconcileResult.reconciled} job(s) with local library`
);
this.emptyQueueChecks = 0; // Reset counter
}
// PART 0.5: Check for stuck discovery batches (batch-level timeout) // PART 0.5: Check for stuck discovery batches (batch-level timeout)
const { discoverWeeklyService } = await import( const discoverWeeklyService = await this.getDiscoverWeeklyService();
"../services/discoverWeekly"
);
const stuckBatchCount = const stuckBatchCount =
await discoverWeeklyService.checkStuckBatches(); await discoverWeeklyService.checkStuckBatches();
if (stuckBatchCount > 0) { if (stuckBatchCount > 0) {
console.log( logger.debug(
`⏰ Force-completed ${stuckBatchCount} stuck discovery batch(es)` `⏰ Force-completed ${stuckBatchCount} stuck discovery batch(es)`
); );
this.emptyQueueChecks = 0; // Reset counter this.emptyQueueChecks = 0; // Reset counter
@@ -97,7 +141,7 @@ class QueueCleanerService {
); );
if (cleanResult.removed > 0) { if (cleanResult.removed > 0) {
console.log( logger.debug(
`[CLEANUP] Removed ${cleanResult.removed} stuck download(s) - searching for alternatives` `[CLEANUP] Removed ${cleanResult.removed} stuck download(s) - searching for alternatives`
); );
this.emptyQueueChecks = 0; // Reset counter - queue had activity this.emptyQueueChecks = 0; // Reset counter - queue had activity
@@ -143,7 +187,7 @@ class QueueCleanerService {
}, },
}); });
console.log( logger.debug(
` Updated job ${job.id}: retry ${ ` Updated job ${job.id}: retry ${
currentRetryCount + 1 currentRetryCount + 1
}` }`
@@ -187,10 +231,10 @@ class QueueCleanerService {
const artistName = const artistName =
download.artist?.name || "Unknown Artist"; download.artist?.name || "Unknown Artist";
const albumTitle = download.album?.title || "Unknown Album"; const albumTitle = download.album?.title || "Unknown Album";
console.log( logger.debug(
`Recovered orphaned job: ${artistName} - ${albumTitle}` `Recovered orphaned job: ${artistName} - ${albumTitle}`
); );
console.log(` Download ID: ${download.downloadId}`); logger.debug(` Download ID: ${download.downloadId}`);
this.emptyQueueChecks = 0; // Reset counter - found work to do this.emptyQueueChecks = 0; // Reset counter - found work to do
recoveredCount += orphanedJobs.length; recoveredCount += orphanedJobs.length;
@@ -219,11 +263,9 @@ class QueueCleanerService {
} }
if (discoveryBatchIds.size > 0) { if (discoveryBatchIds.size > 0) {
const { discoverWeeklyService } = await import( const discoverWeeklyService = await this.getDiscoverWeeklyService();
"../services/discoverWeekly"
);
for (const batchId of discoveryBatchIds) { for (const batchId of discoveryBatchIds) {
console.log( logger.debug(
` Checking Discovery batch completion: ${batchId}` ` Checking Discovery batch completion: ${batchId}`
); );
await discoverWeeklyService.checkBatchCompletion( await discoverWeeklyService.checkBatchCompletion(
@@ -238,7 +280,7 @@ class QueueCleanerService {
!j.discoveryBatchId !j.discoveryBatchId
); );
if (nonDiscoveryJobs.length > 0) { if (nonDiscoveryJobs.length > 0) {
console.log( logger.debug(
` Triggering library scan for recovered job(s)...` ` Triggering library scan for recovered job(s)...`
); );
await scanQueue.add("scan", { await scanQueue.add("scan", {
@@ -250,12 +292,12 @@ class QueueCleanerService {
} }
if (recoveredCount > 0) { if (recoveredCount > 0) {
console.log(`Recovered ${recoveredCount} orphaned job(s)`); logger.debug(`Recovered ${recoveredCount} orphaned job(s)`);
} }
// Only log skipped count occasionally to reduce noise // Only log skipped count occasionally to reduce noise
if (skippedCount > 0 && this.emptyQueueChecks === 0) { if (skippedCount > 0 && this.emptyQueueChecks === 0) {
console.log( logger.debug(
` (Skipped ${skippedCount} incomplete download records)` ` (Skipped ${skippedCount} incomplete download records)`
); );
} }
@@ -272,12 +314,12 @@ class QueueCleanerService {
if (!hadActivity) { if (!hadActivity) {
this.emptyQueueChecks++; this.emptyQueueChecks++;
console.log( logger.debug(
` Queue empty (${this.emptyQueueChecks}/${this.maxEmptyChecks})` ` Queue empty (${this.emptyQueueChecks}/${this.maxEmptyChecks})`
); );
if (this.emptyQueueChecks >= this.maxEmptyChecks) { if (this.emptyQueueChecks >= this.maxEmptyChecks) {
console.log( logger.debug(
` No activity for ${this.maxEmptyChecks} checks - stopping cleaner` ` No activity for ${this.maxEmptyChecks} checks - stopping cleaner`
); );
this.stop(); this.stop();
@@ -293,7 +335,7 @@ class QueueCleanerService {
this.checkInterval this.checkInterval
); );
} catch (error) { } catch (error) {
console.error(" Queue cleanup error:", error); logger.error(" Queue cleanup error:", error);
// Still schedule next check even on error // Still schedule next check even on error
this.timeoutId = setTimeout( this.timeoutId = setTimeout(
() => this.runCleanup(), () => this.runCleanup(),
@@ -302,6 +344,171 @@ class QueueCleanerService {
} }
} }
/**
* Reconcile processing jobs with local library (Phase 1 & 3 fix for #31)
* Checks if albums already exist in Lidify's database and marks matching jobs as complete
* This handles cases where:
* - Lidarr webhooks were missed
* - MBID mismatches between MusicBrainz and Lidarr
* - Album/artist name differences prevent webhook matching
*
* Phase 3 enhancement: Uses fuzzy matching to catch more name variations
*
* PUBLIC: Called by periodic reconciliation in workers/index.ts
*/
async reconcileWithLocalLibrary(): Promise<{ reconciled: number }> {
const processingJobs = await prisma.downloadJob.findMany({
where: { status: { in: ["pending", "processing"] } },
});
if (processingJobs.length === 0) {
return { reconciled: 0 };
}
logger.debug(
`[LOCAL-RECONCILE] Checking ${processingJobs.length} job(s) against local library...`
);
let reconciled = 0;
for (const job of processingJobs) {
const metadata = (job.metadata as any) || {};
const artistName = metadata?.artistName;
const albumTitle = metadata?.albumTitle;
if (!artistName || !albumTitle) {
continue;
}
try {
// First try: Exact/contains match (fast)
let localAlbum = await prisma.album.findFirst({
where: {
AND: [
{
artist: {
name: {
contains: artistName,
mode: "insensitive",
},
},
},
{
title: {
contains: albumTitle,
mode: "insensitive",
},
},
],
},
include: {
tracks: {
select: { id: true },
take: 1,
},
artist: {
select: { name: true },
},
},
});
// Second try: Fuzzy match if exact match failed (slower but more thorough)
if (!localAlbum || localAlbum.tracks.length === 0) {
const matchAlbum = await this.getMatchAlbum();
// Get all albums from artists with similar names
const candidateAlbums = await prisma.album.findMany({
where: {
artist: {
name: {
contains: artistName.substring(0, 5),
mode: "insensitive",
},
},
},
include: {
tracks: {
select: { id: true },
take: 1,
},
artist: {
select: { name: true },
},
},
take: 50, // Limit to prevent performance issues
});
// Find best fuzzy match
const fuzzyMatch = candidateAlbums.find(
(album) =>
album.tracks.length > 0 &&
matchAlbum(
artistName,
albumTitle,
album.artist.name,
album.title,
0.75
)
);
if (fuzzyMatch) {
localAlbum = fuzzyMatch;
}
if (localAlbum) {
logger.debug(
`[LOCAL-RECONCILE] Fuzzy matched "${artistName} - ${albumTitle}" to "${localAlbum.artist.name} - ${localAlbum.title}"`
);
}
}
if (localAlbum && localAlbum.tracks.length > 0) {
logger.debug(
`[LOCAL-RECONCILE] ✓ Found "${localAlbum.artist.name} - ${localAlbum.title}" in library for job ${job.id}`
);
// Album exists with tracks - mark job complete
await prisma.downloadJob.update({
where: { id: job.id },
data: {
status: "completed",
completedAt: new Date(),
error: null,
metadata: {
...metadata,
completedAt: new Date().toISOString(),
reconciledFromLocalLibrary: true,
},
},
});
reconciled++;
// Check batch completion for discovery jobs
if (job.discoveryBatchId) {
const discoverWeeklyService = await this.getDiscoverWeeklyService();
await discoverWeeklyService.checkBatchCompletion(
job.discoveryBatchId
);
}
}
} catch (error: any) {
logger.error(
`[LOCAL-RECONCILE] Error checking job ${job.id}:`,
error.message
);
}
}
if (reconciled > 0) {
logger.debug(
`[LOCAL-RECONCILE] Marked ${reconciled} job(s) complete from local library`
);
}
return { reconciled };
}
/** /**
* Get current status (for debugging/monitoring) * Get current status (for debugging/monitoring)
*/ */
+187 -81
View File
@@ -1,4 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
@@ -11,6 +12,9 @@ if (!JWT_SECRET) {
); );
} }
// Type assertion after validation - JWT_SECRET is guaranteed to be a string
const JWT_SECRET_VALIDATED: string = JWT_SECRET;
declare global { declare global {
namespace Express { namespace Express {
interface Request { interface Request {
@@ -23,91 +27,177 @@ declare global {
} }
} }
export interface AuthenticatedRequest extends Request {
user: {
id: string;
username: string;
role: string;
};
}
export interface JWTPayload { export interface JWTPayload {
userId: string; userId: string;
username: string; username: string;
role: string; role: string;
tokenVersion?: number;
type?: string;
} }
export function generateToken(user: { id: string; username: string; role: string }): string { export function generateToken(user: {
id: string;
username: string;
role: string;
tokenVersion: number;
}): string {
return jwt.sign( return jwt.sign(
{ userId: user.id, username: user.username, role: user.role }, {
JWT_SECRET, userId: user.id,
username: user.username,
role: user.role,
tokenVersion: user.tokenVersion
},
JWT_SECRET_VALIDATED,
{ expiresIn: "24h" }
);
}
export function generateRefreshToken(user: {
id: string;
tokenVersion: number;
}): string {
return jwt.sign(
{
userId: user.id,
tokenVersion: user.tokenVersion,
type: "refresh"
},
JWT_SECRET_VALIDATED,
{ expiresIn: "30d" } { expiresIn: "30d" }
); );
} }
/**
* Helper function to authenticate a request using session, API key, or JWT
* @param req Express request object
* @param checkQueryToken Whether to check for token in query params (for streaming)
* @returns User object if authenticated, null otherwise
*/
async function authenticateRequest(
req: Request,
checkQueryToken: boolean = false
): Promise<{ id: string; username: string; role: string } | null> {
// Check session-based auth
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) return user;
} catch (error) {
logger.error("Session auth error:", error);
}
}
// Check for API key in X-API-Key header
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(() => {});
return apiKeyRecord.user;
}
} catch (error) {
logger.error("API key auth error:", error);
}
}
// Check for token in query param (for streaming URLs)
if (checkQueryToken) {
const tokenParam = req.query.token as string;
if (tokenParam) {
try {
const decoded = jwt.verify(
tokenParam,
JWT_SECRET_VALIDATED
) as unknown as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true },
});
if (user) {
// Validate tokenVersion - reject if password was changed
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
return null;
}
return { id: user.id, username: user.username, role: user.role };
}
} catch (error) {
// Token invalid, try other methods
}
}
}
// 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_VALIDATED) as unknown as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true },
});
if (user) {
// Validate tokenVersion - reject if password was changed
if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
return null;
}
return { id: user.id, username: user.username, role: user.role };
}
} catch (error) {
// Token invalid
}
}
return null;
}
export async function requireAuth( export async function requireAuth(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction
) { ) {
// First, check session-based auth (primary method) const user = await authenticateRequest(req, false);
if (req.session?.userId) { if (user) {
try { req.user = user;
const user = await prisma.user.findUnique({ return next();
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" }); return res.status(401).json({ error: "Not authenticated" });
} }
export async function requireAdmin(req: Request, res: Response, next: NextFunction) { export async function requireAdmin(
req: Request,
res: Response,
next: NextFunction
) {
if (!req.user || req.user.role !== "admin") { if (!req.user || req.user.role !== "admin") {
return res.status(403).json({ error: "Admin access required" }); return res.status(403).json({ error: "Admin access required" });
} }
@@ -133,7 +223,7 @@ export async function requireAuthOrToken(
return next(); return next();
} }
} catch (error) { } catch (error) {
console.error("Session auth error:", error); logger.error("Session auth error:", error);
} }
} }
@@ -143,21 +233,25 @@ export async function requireAuthOrToken(
try { try {
const apiKeyRecord = await prisma.apiKey.findUnique({ const apiKeyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey }, where: { key: apiKey },
include: { user: { select: { id: true, username: true, role: true } } }, include: {
user: { select: { id: true, username: true, role: true } },
},
}); });
if (apiKeyRecord && apiKeyRecord.user) { if (apiKeyRecord && apiKeyRecord.user) {
// Update last used timestamp (async, don't block) // Update last used timestamp (async, don't block)
prisma.apiKey.update({ prisma.apiKey
where: { id: apiKeyRecord.id }, .update({
data: { lastUsed: new Date() }, where: { id: apiKeyRecord.id },
}).catch(() => {}); // Ignore errors on lastUsed update data: { lastUsed: new Date() },
})
.catch(() => {}); // Ignore errors on lastUsed update
req.user = apiKeyRecord.user; req.user = apiKeyRecord.user;
return next(); return next();
} }
} catch (error) { } catch (error) {
console.error("API key auth error:", error); logger.error("API key auth error:", error);
} }
} }
@@ -165,15 +259,20 @@ export async function requireAuthOrToken(
const tokenParam = req.query.token as string; const tokenParam = req.query.token as string;
if (tokenParam) { if (tokenParam) {
try { try {
const decoded = jwt.verify(tokenParam, JWT_SECRET) as JWTPayload; const decoded = jwt.verify(tokenParam, JWT_SECRET_VALIDATED) as unknown as JWTPayload;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.userId }, where: { id: decoded.userId },
select: { id: true, username: true, role: true }, select: { id: true, username: true, role: true, tokenVersion: true },
}); });
if (user) { if (user) {
req.user = user; // Validate tokenVersion - reject if password was changed
return next(); if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
// Token was issued before password change, reject
} else {
req.user = { id: user.id, username: user.username, role: user.role };
return next();
}
} }
} catch (error) { } catch (error) {
// Token invalid, try other methods // Token invalid, try other methods
@@ -182,19 +281,26 @@ export async function requireAuthOrToken(
// Fallback: check JWT token in Authorization header // Fallback: check JWT token in Authorization header
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null; const token = authHeader?.startsWith("Bearer ")
? authHeader.substring(7)
: null;
if (token) { if (token) {
try { try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; const decoded = jwt.verify(token, JWT_SECRET_VALIDATED) as unknown as JWTPayload;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.userId }, where: { id: decoded.userId },
select: { id: true, username: true, role: true }, select: { id: true, username: true, role: true, tokenVersion: true },
}); });
if (user) { if (user) {
req.user = user; // Validate tokenVersion - reject if password was changed
return next(); if (decoded.tokenVersion === undefined || decoded.tokenVersion !== user.tokenVersion) {
// Token was issued before password change, reject
} else {
req.user = { id: user.id, username: user.username, role: user.role };
return next();
}
} }
} catch (error) { } catch (error) {
// Token invalid, continue to error // Token invalid, continue to error
+42 -2
View File
@@ -1,4 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { logger } from "../utils/logger";
import { AppError, ErrorCategory } from "../utils/errors";
import { config } from "../config";
export function errorHandler( export function errorHandler(
err: Error, err: Error,
@@ -6,6 +9,43 @@ export function errorHandler(
res: Response, res: Response,
next: NextFunction next: NextFunction
) { ) {
console.error(err.stack); // Handle AppError with proper categorization
res.status(500).json({ error: "Internal server error" }); if (err instanceof AppError) {
// Map error category to HTTP status code
let statusCode = 500;
switch (err.category) {
case ErrorCategory.RECOVERABLE:
statusCode = 400; // Bad Request - client can retry with changes
break;
case ErrorCategory.TRANSIENT:
statusCode = 503; // Service Unavailable - client can retry later
break;
case ErrorCategory.FATAL:
statusCode = 500; // Internal Server Error - cannot recover
break;
}
logger.error(`[AppError] ${err.code}: ${err.message}`, err.details);
return res.status(statusCode).json({
error: err.message,
code: err.code,
category: err.category,
...(config.nodeEnv === "development" && { details: err.details }),
});
}
// Log stack trace for unhandled errors
logger.error("Unhandled error:", err.stack);
// In production, hide stack traces and internal details
if (config.nodeEnv === "production") {
return res.status(500).json({ error: "Internal server error" });
}
// In development, provide more details
res.status(500).json({
error: err.message || "Internal server error",
stack: err.stack,
});
} }
+78 -12
View File
@@ -1,7 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { requireAuth, requireAdmin } from "../middleware/auth"; import { requireAuth, requireAdmin } from "../middleware/auth";
import { getSystemSettings } from "../utils/systemSettings";
import os from "os";
const router = Router(); const router = Router();
@@ -42,7 +45,7 @@ router.get("/status", requireAuth, async (req, res) => {
isComplete: pending === 0 && processing === 0 && queueLength === 0, isComplete: pending === 0 && processing === 0 && queueLength === 0,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Analysis status error:", error); logger.error("Analysis status error:", error);
res.status(500).json({ error: "Failed to get analysis status" }); res.status(500).json({ error: "Failed to get analysis status" });
} }
}); });
@@ -87,14 +90,14 @@ router.post("/start", requireAuth, requireAdmin, async (req, res) => {
} }
await pipeline.exec(); await pipeline.exec();
console.log(`Queued ${tracks.length} tracks for audio analysis`); logger.debug(`Queued ${tracks.length} tracks for audio analysis`);
res.json({ res.json({
message: `Queued ${tracks.length} tracks for analysis`, message: `Queued ${tracks.length} tracks for analysis`,
queued: tracks.length, queued: tracks.length,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Analysis start error:", error); logger.error("Analysis start error:", error);
res.status(500).json({ error: "Failed to start analysis" }); res.status(500).json({ error: "Failed to start analysis" });
} }
}); });
@@ -121,7 +124,7 @@ router.post("/retry-failed", requireAuth, requireAdmin, async (req, res) => {
reset: result.count, reset: result.count,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Retry failed error:", error); logger.error("Retry failed error:", error);
res.status(500).json({ error: "Failed to retry analysis" }); res.status(500).json({ error: "Failed to retry analysis" });
} }
}); });
@@ -166,7 +169,7 @@ router.post("/analyze/:trackId", requireAuth, async (req, res) => {
trackId, trackId,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Analyze track error:", error); logger.error("Analyze track error:", error);
res.status(500).json({ error: "Failed to queue track for analysis" }); res.status(500).json({ error: "Failed to queue track for analysis" });
} }
}); });
@@ -214,7 +217,7 @@ router.get("/track/:trackId", requireAuth, async (req, res) => {
res.json(track); res.json(track);
} catch (error: any) { } catch (error: any) {
console.error("Get track analysis error:", error); logger.error("Get track analysis error:", error);
res.status(500).json({ error: "Failed to get track analysis" }); res.status(500).json({ error: "Failed to get track analysis" });
} }
}); });
@@ -280,14 +283,77 @@ router.get("/features", requireAuth, async (req, res) => {
}, },
}); });
} catch (error: any) { } catch (error: any) {
console.error("Get features error:", error); logger.error("Get features error:", error);
res.status(500).json({ error: "Failed to get feature statistics" }); res.status(500).json({ error: "Failed to get feature statistics" });
} }
}); });
/**
* GET /api/analysis/workers
* Get current audio analyzer worker configuration
*/
router.get("/workers", requireAuth, requireAdmin, async (req, res) => {
try {
const settings = await getSystemSettings();
const cpuCores = os.cpus().length;
const currentWorkers = settings?.audioAnalyzerWorkers || 2;
// Recommended: 50% of CPU cores, min 2, max 8
const recommended = Math.max(2, Math.min(8, Math.floor(cpuCores / 2)));
res.json({
workers: currentWorkers,
cpuCores,
recommended,
description: `Using ${currentWorkers} of ${cpuCores} available CPU cores`,
});
} catch (error: any) {
logger.error("Get workers config error:", error);
res.status(500).json({ error: "Failed to get worker configuration" });
}
});
/**
* PUT /api/analysis/workers
* Update audio analyzer worker count
*/
router.put("/workers", requireAuth, requireAdmin, async (req, res) => {
try {
const { workers } = req.body;
if (typeof workers !== 'number' || workers < 1 || workers > 8) {
return res.status(400).json({
error: "Workers must be a number between 1 and 8"
});
}
// Update SystemSettings
await prisma.systemSettings.update({
where: { id: "default" },
data: { audioAnalyzerWorkers: workers },
});
// Publish control signal to Redis for Python worker to pick up
await redisClient.publish(
"audio:analysis:control",
JSON.stringify({ command: "set_workers", count: workers })
);
const cpuCores = os.cpus().length;
const recommended = Math.max(2, Math.min(8, Math.floor(cpuCores / 2)));
logger.info(`Audio analyzer workers updated to ${workers}`);
res.json({
workers,
cpuCores,
recommended,
description: `Using ${workers} of ${cpuCores} available CPU cores`,
});
} catch (error: any) {
logger.error("Update workers config error:", error);
res.status(500).json({ error: "Failed to update worker configuration" });
}
});
export default router; export default router;
+6 -5
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import crypto from "crypto"; import crypto from "crypto";
@@ -88,7 +89,7 @@ router.post("/", async (req, res) => {
}, },
}); });
console.log(`API key created for user ${userId}: ${deviceName}`); logger.debug(`API key created for user ${userId}: ${deviceName}`);
res.status(201).json({ res.status(201).json({
apiKey: apiKey.key, apiKey: apiKey.key,
@@ -98,7 +99,7 @@ router.post("/", async (req, res) => {
"API key created successfully. Save this key - you won't see it again!", "API key created successfully. Save this key - you won't see it again!",
}); });
} catch (error) { } catch (error) {
console.error("Create API key error:", error); logger.error("Create API key error:", error);
res.status(500).json({ error: "Failed to create API key" }); res.status(500).json({ error: "Failed to create API key" });
} }
}); });
@@ -152,7 +153,7 @@ router.get("/", async (req, res) => {
res.json({ apiKeys: keys }); res.json({ apiKeys: keys });
} catch (error) { } catch (error) {
console.error("List API keys error:", error); logger.error("List API keys error:", error);
res.status(500).json({ error: "Failed to list API keys" }); res.status(500).json({ error: "Failed to list API keys" });
} }
}); });
@@ -219,11 +220,11 @@ router.delete("/:id", async (req, res) => {
.json({ error: "API key not found or already deleted" }); .json({ error: "API key not found or already deleted" });
} }
console.log(`API key ${keyId} revoked by user ${userId}`); logger.debug(`API key ${keyId} revoked by user ${userId}`);
res.json({ message: "API key revoked successfully" }); res.json({ message: "API key revoked successfully" });
} catch (error) { } catch (error) {
console.error("Delete API key error:", error); logger.error("Delete API key error:", error);
res.status(500).json({ error: "Failed to revoke API key" }); res.status(500).json({ error: "Failed to revoke API key" });
} }
}); });
+51 -33
View File
@@ -1,9 +1,11 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { lastFmService } from "../services/lastfm"; import { lastFmService } from "../services/lastfm";
import { musicBrainzService } from "../services/musicbrainz"; import { musicBrainzService } from "../services/musicbrainz";
import { fanartService } from "../services/fanart"; import { fanartService } from "../services/fanart";
import { deezerService } from "../services/deezer"; import { deezerService } from "../services/deezer";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { normalizeToArray } from "../utils/normalize";
const router = Router(); const router = Router();
@@ -17,7 +19,7 @@ router.get("/preview/:artistName/:trackTitle", async (req, res) => {
const decodedArtist = decodeURIComponent(artistName); const decodedArtist = decodeURIComponent(artistName);
const decodedTrack = decodeURIComponent(trackTitle); const decodedTrack = decodeURIComponent(trackTitle);
console.log( logger.debug(
`Getting preview for "${decodedTrack}" by ${decodedArtist}` `Getting preview for "${decodedTrack}" by ${decodedArtist}`
); );
@@ -32,7 +34,7 @@ router.get("/preview/:artistName/:trackTitle", async (req, res) => {
res.status(404).json({ error: "Preview not found" }); res.status(404).json({ error: "Preview not found" });
} }
} catch (error: any) { } catch (error: any) {
console.error("Preview fetch error:", error); logger.error("Preview fetch error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch preview", error: "Failed to fetch preview",
message: error.message, message: error.message,
@@ -50,7 +52,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
try { try {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log(`[Discovery] Cache hit for artist: ${nameOrMbid}`); logger.debug(`[Discovery] Cache hit for artist: ${nameOrMbid}`);
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
} catch (err) { } catch (err) {
@@ -108,7 +110,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
lowerBio.includes("multiple artists") lowerBio.includes("multiple artists")
) { ) {
// This is a disambiguation page - don't show it // This is a disambiguation page - don't show it
console.log( logger.debug(
` Filtered out disambiguation biography for ${artistName}` ` Filtered out disambiguation biography for ${artistName}`
); );
bio = null; bio = null;
@@ -125,7 +127,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
10 10
); );
} catch (error) { } catch (error) {
console.log(`Failed to get top tracks for ${artistName}`); logger.debug(`Failed to get top tracks for ${artistName}`);
} }
} }
@@ -136,9 +138,9 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
if (mbid) { if (mbid) {
try { try {
image = await fanartService.getArtistImage(mbid); image = await fanartService.getArtistImage(mbid);
console.log(`Fanart.tv image for ${artistName}`); logger.debug(`Fanart.tv image for ${artistName}`);
} catch (error) { } catch (error) {
console.log( logger.debug(
`✗ Failed to get Fanart.tv image for ${artistName}` `✗ Failed to get Fanart.tv image for ${artistName}`
); );
} }
@@ -149,25 +151,27 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
try { try {
image = await deezerService.getArtistImage(artistName); image = await deezerService.getArtistImage(artistName);
if (image) { if (image) {
console.log(`Deezer image for ${artistName}`); logger.debug(`Deezer image for ${artistName}`);
} }
} catch (error) { } catch (error) {
console.log(` Failed to get Deezer image for ${artistName}`); logger.debug(` Failed to get Deezer image for ${artistName}`);
} }
} }
// Fallback to Last.fm (but filter placeholders) // Fallback to Last.fm (but filter placeholders)
// NORMALIZATION: lastFmInfo.image could be a single object or array
if (!image && lastFmInfo?.image) { if (!image && lastFmInfo?.image) {
const lastFmImage = lastFmService.getBestImage(lastFmInfo.image); const images = normalizeToArray(lastFmInfo.image);
const lastFmImage = lastFmService.getBestImage(images);
// Filter out Last.fm placeholder // Filter out Last.fm placeholder
if ( if (
lastFmImage && lastFmImage &&
!lastFmImage.includes("2a96cbd8b46e442fc41c2b86b821562f") !lastFmImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
) { ) {
image = lastFmImage; image = lastFmImage;
console.log(`Last.fm image for ${artistName}`); logger.debug(`Last.fm image for ${artistName}`);
} else { } else {
console.log(` Last.fm returned placeholder for ${artistName}`); logger.debug(` Last.fm returned placeholder for ${artistName}`);
} }
} }
@@ -265,7 +269,7 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
return 0; return 0;
}); });
} catch (error) { } catch (error) {
console.error( logger.error(
`Failed to get discography for ${artistName}:`, `Failed to get discography for ${artistName}:`,
error error
); );
@@ -273,10 +277,13 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
} }
// Get similar artists from Last.fm and fetch images // Get similar artists from Last.fm and fetch images
const similarArtistsRaw = lastFmInfo?.similar?.artist || []; // NORMALIZATION: lastFmInfo.similar.artist could be a single object or array
const similarArtistsRaw = normalizeToArray(lastFmInfo?.similar?.artist);
const similarArtists = await Promise.all( const similarArtists = await Promise.all(
similarArtistsRaw.slice(0, 10).map(async (artist: any) => { similarArtistsRaw.slice(0, 10).map(async (artist: any) => {
const similarImage = artist.image?.find( // NORMALIZATION: artist.image could be a single object or array
const images = normalizeToArray(artist.image);
const similarImage = images.find(
(img: any) => img.size === "large" (img: any) => img.size === "large"
)?.[" #text"]; )?.[" #text"];
@@ -324,14 +331,19 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
}) })
); );
// NORMALIZATION: lastFmInfo.tags.tag could be a single object or array
const tags = normalizeToArray(lastFmInfo?.tags?.tag)
.map((t: any) => t?.name)
.filter(Boolean);
const response = { const response = {
mbid, mbid,
name: artistName, name: artistName,
image, image,
bio, // Use filtered bio instead of raw Last.fm bio bio, // Use filtered bio instead of raw Last.fm bio
summary: bio, // Alias for consistency summary: bio, // Alias for consistency
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], tags,
genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency genres: tags, // Alias for consistency
listeners: parseInt(lastFmInfo?.stats?.listeners || "0"), listeners: parseInt(lastFmInfo?.stats?.listeners || "0"),
playcount: parseInt(lastFmInfo?.stats?.playcount || "0"), playcount: parseInt(lastFmInfo?.stats?.playcount || "0"),
url: lastFmInfo?.url || null, url: lastFmInfo?.url || null,
@@ -355,14 +367,14 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
DISCOVERY_CACHE_TTL, DISCOVERY_CACHE_TTL,
JSON.stringify(response) JSON.stringify(response)
); );
console.log(`[Discovery] Cached artist: ${artistName}`); logger.debug(`[Discovery] Cached artist: ${artistName}`);
} catch (err) { } catch (err) {
// Redis errors are non-critical // Redis errors are non-critical
} }
res.json(response); res.json(response);
} catch (error: any) { } catch (error: any) {
console.error("Artist discovery error:", error); logger.error("Artist discovery error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch artist details", error: "Failed to fetch artist details",
message: error.message, message: error.message,
@@ -380,7 +392,7 @@ router.get("/album/:mbid", async (req, res) => {
try { try {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log(`[Discovery] Cache hit for album: ${mbid}`); logger.debug(`[Discovery] Cache hit for album: ${mbid}`);
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
} catch (err) { } catch (err) {
@@ -397,7 +409,7 @@ router.get("/album/:mbid", async (req, res) => {
} catch (error: any) { } catch (error: any) {
// If 404, try as a release instead // If 404, try as a release instead
if (error.response?.status === 404) { if (error.response?.status === 404) {
console.log( logger.debug(
`${mbid} is not a release-group, trying as release...` `${mbid} is not a release-group, trying as release...`
); );
release = await musicBrainzService.getRelease(mbid); release = await musicBrainzService.getRelease(mbid);
@@ -410,7 +422,7 @@ router.get("/album/:mbid", async (req, res) => {
releaseGroupId releaseGroupId
); );
} catch (err) { } catch (err) {
console.error( logger.error(
`Failed to get release-group ${releaseGroupId}` `Failed to get release-group ${releaseGroupId}`
); );
} }
@@ -439,7 +451,7 @@ router.get("/album/:mbid", async (req, res) => {
albumTitle albumTitle
); );
} catch (error) { } catch (error) {
console.log(`Failed to get Last.fm info for ${albumTitle}`); logger.debug(`Failed to get Last.fm info for ${albumTitle}`);
} }
// Get tracks - if we have release, use it directly; otherwise get first release from group // Get tracks - if we have release, use it directly; otherwise get first release from group
@@ -454,7 +466,7 @@ router.get("/album/:mbid", async (req, res) => {
); );
tracks = releaseDetails.media?.[0]?.tracks || []; tracks = releaseDetails.media?.[0]?.tracks || [];
} catch (error) { } catch (error) {
console.error( logger.error(
`Failed to get tracks for release ${firstRelease.id}` `Failed to get tracks for release ${firstRelease.id}`
); );
} }
@@ -469,17 +481,20 @@ router.get("/album/:mbid", async (req, res) => {
// Check if Cover Art Archive actually has the image // Check if Cover Art Archive actually has the image
try { try {
const response = await fetch(coverArtUrl, { method: "HEAD" }); const response = await fetch(coverArtUrl, {
method: "HEAD",
signal: AbortSignal.timeout(2000),
});
if (response.ok) { if (response.ok) {
coverUrl = coverArtUrl; coverUrl = coverArtUrl;
console.log(`Cover Art Archive has cover for ${albumTitle}`); logger.debug(`Cover Art Archive has cover for ${albumTitle}`);
} else { } else {
console.log( logger.debug(
`✗ Cover Art Archive 404 for ${albumTitle}, trying Deezer...` `✗ Cover Art Archive 404 for ${albumTitle}, trying Deezer...`
); );
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
`✗ Cover Art Archive check failed for ${albumTitle}, trying Deezer...` `✗ Cover Art Archive check failed for ${albumTitle}, trying Deezer...`
); );
} }
@@ -493,13 +508,13 @@ router.get("/album/:mbid", async (req, res) => {
); );
if (deezerCover) { if (deezerCover) {
coverUrl = deezerCover; coverUrl = deezerCover;
console.log(`Deezer has cover for ${albumTitle}`); logger.debug(`Deezer has cover for ${albumTitle}`);
} else { } else {
// Final fallback to Cover Art Archive URL (might 404, but better than nothing) // Final fallback to Cover Art Archive URL (might 404, but better than nothing)
coverUrl = coverArtUrl; coverUrl = coverArtUrl;
} }
} catch (error) { } catch (error) {
console.log(` Deezer lookup failed for ${albumTitle}`); logger.debug(` Deezer lookup failed for ${albumTitle}`);
// Final fallback to Cover Art Archive URL // Final fallback to Cover Art Archive URL
coverUrl = coverArtUrl; coverUrl = coverArtUrl;
} }
@@ -528,7 +543,10 @@ router.get("/album/:mbid", async (req, res) => {
coverUrl, coverUrl,
coverArt: coverUrl, // Alias for compatibility coverArt: coverUrl, // Alias for compatibility
bio: lastFmInfo?.wiki?.summary || null, bio: lastFmInfo?.wiki?.summary || null,
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // NORMALIZATION: lastFmInfo.tags.tag could be a single object or array
tags: normalizeToArray(lastFmInfo?.tags?.tag)
.map((t: any) => t?.name)
.filter(Boolean),
tracks: tracks.map((track: any, index: number) => ({ tracks: tracks.map((track: any, index: number) => ({
id: `mb-${releaseGroupId}-${track.id || index}`, id: `mb-${releaseGroupId}-${track.id || index}`,
title: track.title, title: track.title,
@@ -548,14 +566,14 @@ router.get("/album/:mbid", async (req, res) => {
DISCOVERY_CACHE_TTL, DISCOVERY_CACHE_TTL,
JSON.stringify(response) JSON.stringify(response)
); );
console.log(`[Discovery] Cached album: ${albumTitle}`); logger.debug(`[Discovery] Cached album: ${albumTitle}`);
} catch (err) { } catch (err) {
// Redis errors are non-critical // Redis errors are non-critical
} }
res.json(response); res.json(response);
} catch (error: any) { } catch (error: any) {
console.error("Album discovery error:", error); logger.error("Album discovery error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch album details", error: "Failed to fetch album details",
message: error.message, message: error.message,
+91 -57
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { audiobookshelfService } from "../services/audiobookshelf"; import { audiobookshelfService } from "../services/audiobookshelf";
import { audiobookCacheService } from "../services/audiobookCache"; import { audiobookCacheService } from "../services/audiobookCache";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
@@ -57,7 +58,7 @@ router.get(
res.json(transformed); res.json(transformed);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching continue listening:", error); logger.error("Error fetching continue listening:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch continue listening", error: "Failed to fetch continue listening",
message: error.message, message: error.message,
@@ -83,14 +84,14 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
.json({ error: "Audiobookshelf not enabled" }); .json({ error: "Audiobookshelf not enabled" });
} }
console.log("[Audiobooks] Starting manual audiobook sync..."); logger.debug("[Audiobooks] Starting manual audiobook sync...");
const result = await audiobookCacheService.syncAll(); const result = await audiobookCacheService.syncAll();
// Check how many have series after sync // Check how many have series after sync
const seriesCount = await prisma.audiobook.count({ const seriesCount = await prisma.audiobook.count({
where: { series: { not: null } }, where: { series: { not: null } },
}); });
console.log( logger.debug(
`[Audiobooks] Sync complete. Books with series: ${seriesCount}` `[Audiobooks] Sync complete. Books with series: ${seriesCount}`
); );
@@ -108,7 +109,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
result, result,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Audiobook sync failed:", error); logger.error("Audiobook sync failed:", error);
res.status(500).json({ res.status(500).json({
error: "Sync failed", error: "Sync failed",
message: error.message, message: error.message,
@@ -122,7 +123,7 @@ router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
*/ */
// Debug endpoint for series data // Debug endpoint for series data
router.get("/debug-series", requireAuthOrToken, async (req, res) => { router.get("/debug-series", requireAuthOrToken, async (req, res) => {
console.log("[Audiobooks] Debug series endpoint called"); logger.debug("[Audiobooks] Debug series endpoint called");
try { try {
const { getSystemSettings } = await import("../utils/systemSettings"); const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings(); const settings = await getSystemSettings();
@@ -135,7 +136,7 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
// Get raw data from Audiobookshelf // Get raw data from Audiobookshelf
const rawBooks = await audiobookshelfService.getAllAudiobooks(); const rawBooks = await audiobookshelfService.getAllAudiobooks();
console.log( logger.debug(
`[Audiobooks] Got ${rawBooks.length} books from Audiobookshelf` `[Audiobooks] Got ${rawBooks.length} books from Audiobookshelf`
); );
@@ -145,7 +146,7 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
return metadata.series || metadata.seriesName; return metadata.series || metadata.seriesName;
}); });
console.log( logger.debug(
`[Audiobooks] Books with series data: ${booksWithSeries.length}` `[Audiobooks] Books with series data: ${booksWithSeries.length}`
); );
@@ -179,7 +180,7 @@ router.get("/debug-series", requireAuthOrToken, async (req, res) => {
fullSampleWithSeries: fullSample, fullSampleWithSeries: fullSample,
}); });
} catch (error: any) { } catch (error: any) {
console.error("[Audiobooks] Debug series error:", error); logger.error("[Audiobooks] Debug series error:", error);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -207,7 +208,7 @@ router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
const results = await audiobookshelfService.searchAudiobooks(q); const results = await audiobookshelfService.searchAudiobooks(q);
res.json(results); res.json(results);
} catch (error: any) { } catch (error: any) {
console.error("Error searching audiobooks:", error); logger.error("Error searching audiobooks:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to search audiobooks", error: "Failed to search audiobooks",
message: error.message, message: error.message,
@@ -220,7 +221,7 @@ router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
* Get all audiobooks from cached database (instant, no API calls) * Get all audiobooks from cached database (instant, no API calls)
*/ */
router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => { router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
console.log("[Audiobooks] GET / - fetching audiobooks list"); logger.debug("[Audiobooks] GET / - fetching audiobooks list");
try { try {
// Check if Audiobookshelf is enabled first // Check if Audiobookshelf is enabled first
const { getSystemSettings } = await import("../utils/systemSettings"); const { getSystemSettings } = await import("../utils/systemSettings");
@@ -296,7 +297,7 @@ router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
res.json(audiobooksWithProgress); res.json(audiobooksWithProgress);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching audiobooks:", error); logger.error("Error fetching audiobooks:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch audiobooks", error: "Failed to fetch audiobooks",
message: error.message, message: error.message,
@@ -394,7 +395,7 @@ router.get(
res.json(seriesBooks); res.json(seriesBooks);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching series:", error); logger.error("Error fetching series:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch series", error: "Failed to fetch series",
message: error.message, message: error.message,
@@ -419,7 +420,7 @@ router.options("/:id/cover", (req, res) => {
/** /**
* GET /audiobooks/:id/cover * GET /audiobooks/:id/cover
* Serve cached cover image from local disk (instant, no proxying) * Serve cached cover image from local disk, or proxy from Audiobookshelf if not cached
* NO RATE LIMITING - These are static files served from disk with aggressive caching * NO RATE LIMITING - These are static files served from disk with aggressive caching
*/ */
router.get("/:id/cover", async (req, res) => { router.get("/:id/cover", async (req, res) => {
@@ -431,7 +432,7 @@ router.get("/:id/cover", async (req, res) => {
const audiobook = await prisma.audiobook.findUnique({ const audiobook = await prisma.audiobook.findUnique({
where: { id }, where: { id },
select: { localCoverPath: true }, select: { localCoverPath: true, coverUrl: true },
}); });
let coverPath = audiobook?.localCoverPath; let coverPath = audiobook?.localCoverPath;
@@ -456,25 +457,54 @@ router.get("/:id/cover", async (req, res) => {
} }
} }
if (!coverPath) { // If local cover exists, serve it
return res.status(404).json({ error: "Cover not found" }); if (coverPath && fs.existsSync(coverPath)) {
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");
return res.sendFile(coverPath);
} }
// Verify file exists before sending // Fallback: proxy from Audiobookshelf if coverUrl is available
if (!fs.existsSync(coverPath)) { if (audiobook?.coverUrl) {
return res.status(404).json({ error: "Cover file missing" }); const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (settings?.audiobookshelfUrl && settings?.audiobookshelfApiKey) {
const baseUrl = settings.audiobookshelfUrl.replace(/\/$/, "");
const coverApiUrl = `${baseUrl}/api/${audiobook.coverUrl}`;
try {
const response = await fetch(coverApiUrl, {
headers: {
Authorization: `Bearer ${settings.audiobookshelfApiKey}`,
},
});
if (response.ok) {
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Content-Type", response.headers.get("content-type") || "image/jpeg");
res.setHeader("Cache-Control", "public, max-age=86400"); // 24 hours for proxied
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
// Stream the response body to client
const buffer = await response.arrayBuffer();
return res.send(Buffer.from(buffer));
}
} catch (proxyError: any) {
logger.error(`[Audiobook Cover] Proxy error for ${id}:`, proxyError.message);
}
}
} }
// Serve image from local disk with aggressive caching and CORS headers // No cover available
// Use specific origin instead of * to support credentials mode return res.status(404).json({ error: "Cover not found" });
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) { } catch (error: any) {
console.error("Error serving cover:", error); logger.error("Error serving cover:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to serve cover", error: "Failed to serve cover",
message: error.message, message: error.message,
@@ -509,18 +539,22 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
audiobook.lastSyncedAt < audiobook.lastSyncedAt <
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) { ) {
console.log( logger.debug(
`[AUDIOBOOK] Audiobook ${id} not cached or stale, fetching...` `[AUDIOBOOK] Audiobook ${id} not cached or stale, fetching...`
); );
audiobook = await audiobookCacheService.getAudiobook(id); audiobook = await audiobookCacheService.getAudiobook(id);
} }
if (!audiobook) {
return res.status(404).json({ error: "Audiobook not found" });
}
// Get chapters and audio files from API (these change less frequently) // Get chapters and audio files from API (these change less frequently)
let absBook; let absBook;
try { try {
absBook = await audiobookshelfService.getAudiobook(id); absBook = await audiobookshelfService.getAudiobook(id);
} catch (apiError: any) { } catch (apiError: any) {
console.warn( logger.warn(
` Failed to fetch live data from Audiobookshelf for ${id}, using cached data only:`, ` Failed to fetch live data from Audiobookshelf for ${id}, using cached data only:`,
apiError.message apiError.message
); );
@@ -567,7 +601,7 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
res.json(response); res.json(response);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching audiobook__", error); logger.error("Error fetching audiobook__", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch audiobook", error: "Failed to fetch audiobook",
message: error.message, message: error.message,
@@ -581,17 +615,17 @@ router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
*/ */
router.get("/:id/stream", requireAuthOrToken, async (req, res) => { router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
try { try {
console.log( logger.debug(
`[Audiobook Stream] Request for audiobook: ${req.params.id}` `[Audiobook Stream] Request for audiobook: ${req.params.id}`
); );
console.log(`[Audiobook Stream] User: ${req.user?.id || "unknown"}`); logger.debug(`[Audiobook Stream] User: ${req.user?.id || "unknown"}`);
// Check if Audiobookshelf is enabled // Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings"); const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings(); const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) { if (!settings?.audiobookshelfEnabled) {
console.log("[Audiobook Stream] Audiobookshelf not enabled"); logger.debug("[Audiobook Stream] Audiobookshelf not enabled");
return res return res
.status(503) .status(503)
.json({ error: "Audiobookshelf is not configured" }); .json({ error: "Audiobookshelf is not configured" });
@@ -600,7 +634,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
const { id } = req.params; const { id } = req.params;
const rangeHeader = req.headers.range as string | undefined; const rangeHeader = req.headers.range as string | undefined;
console.log( logger.debug(
`[Audiobook Stream] Fetching stream for ${id}, range: ${ `[Audiobook Stream] Fetching stream for ${id}, range: ${
rangeHeader || "none" rangeHeader || "none"
}` }`
@@ -609,7 +643,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
const { stream, headers, status } = const { stream, headers, status } =
await audiobookshelfService.streamAudiobook(id, rangeHeader); await audiobookshelfService.streamAudiobook(id, rangeHeader);
console.log( logger.debug(
`[Audiobook Stream] Got stream, status: ${status}, content-type: ${headers["content-type"]}` `[Audiobook Stream] Got stream, status: ${status}, content-type: ${headers["content-type"]}`
); );
@@ -645,7 +679,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
stream.pipe(res); stream.pipe(res);
stream.on("error", (error: any) => { stream.on("error", (error: any) => {
console.error("[Audiobook Stream] Stream error:", error); logger.error("[Audiobook Stream] Stream error:", error);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: "Failed to stream audiobook", error: "Failed to stream audiobook",
@@ -656,7 +690,7 @@ router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
} }
}); });
} catch (error: any) { } catch (error: any) {
console.error("[Audiobook Stream] Error:", error.message); logger.error("[Audiobook Stream] Error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Failed to stream audiobook", error: "Failed to stream audiobook",
message: error.message, message: error.message,
@@ -704,30 +738,30 @@ router.post(
? Math.max(rawDuration, 0) ? Math.max(rawDuration, 0)
: 0; : 0;
console.log(`\n [AUDIOBOOK PROGRESS] Received update:`); logger.debug(`\n [AUDIOBOOK PROGRESS] Received update:`);
console.log(` User: ${req.user!.username}`); logger.debug(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`); logger.debug(` Audiobook ID: ${id}`);
console.log( logger.debug(
` Current Time: ${currentTime}s (${Math.floor( ` Current Time: ${currentTime}s (${Math.floor(
currentTime / 60 currentTime / 60
)} mins)` )} mins)`
); );
console.log( logger.debug(
` Duration: ${durationValue}s (${Math.floor( ` Duration: ${durationValue}s (${Math.floor(
durationValue / 60 durationValue / 60
)} mins)` )} mins)`
); );
if (durationValue > 0) { if (durationValue > 0) {
console.log( logger.debug(
` Progress: ${( ` Progress: ${(
(currentTime / durationValue) * (currentTime / durationValue) *
100 100
).toFixed(1)}%` ).toFixed(1)}%`
); );
} else { } else {
console.log(" Progress: duration unknown"); logger.debug(" Progress: duration unknown");
} }
console.log(` Finished: ${!!isFinished}`); logger.debug(` Finished: ${!!isFinished}`);
// Pull cached metadata to avoid hitting Audiobookshelf for every update // Pull cached metadata to avoid hitting Audiobookshelf for every update
const [cachedAudiobook, existingProgress] = await Promise.all([ const [cachedAudiobook, existingProgress] = await Promise.all([
@@ -799,7 +833,7 @@ router.post(
}, },
}); });
console.log(` Progress saved to database`); logger.debug(` Progress saved to database`);
// Also update progress in Audiobookshelf // Also update progress in Audiobookshelf
try { try {
@@ -809,9 +843,9 @@ router.post(
fallbackDuration, fallbackDuration,
isFinished isFinished
); );
console.log(` Progress synced to Audiobookshelf`); logger.debug(` Progress synced to Audiobookshelf`);
} catch (error) { } catch (error) {
console.error( logger.error(
"Failed to sync progress to Audiobookshelf:", "Failed to sync progress to Audiobookshelf:",
error error
); );
@@ -830,7 +864,7 @@ router.post(
}, },
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error updating progress:", error); logger.error("Error updating progress:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to update progress", error: "Failed to update progress",
message: error.message, message: error.message,
@@ -864,9 +898,9 @@ router.delete(
const { id } = req.params; const { id } = req.params;
console.log(`\n[AUDIOBOOK PROGRESS] Removing progress:`); logger.debug(`\n[AUDIOBOOK PROGRESS] Removing progress:`);
console.log(` User: ${req.user!.username}`); logger.debug(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`); logger.debug(` Audiobook ID: ${id}`);
// Delete progress from our database // Delete progress from our database
await prisma.audiobookProgress.deleteMany({ await prisma.audiobookProgress.deleteMany({
@@ -876,14 +910,14 @@ router.delete(
}, },
}); });
console.log(` Progress removed from database`); logger.debug(` Progress removed from database`);
// Also remove progress from Audiobookshelf // Also remove progress from Audiobookshelf
try { try {
await audiobookshelfService.updateProgress(id, 0, 0, false); await audiobookshelfService.updateProgress(id, 0, 0, false);
console.log(` Progress reset in Audiobookshelf`); logger.debug(` Progress reset in Audiobookshelf`);
} catch (error) { } catch (error) {
console.error( logger.error(
"Failed to reset progress in Audiobookshelf:", "Failed to reset progress in Audiobookshelf:",
error error
); );
@@ -895,7 +929,7 @@ router.delete(
message: "Progress removed", message: "Progress removed",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error removing progress:", error); logger.error("Error removing progress:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to remove progress", error: "Failed to remove progress",
message: error.message, message: error.message,
+65 -13
View File
@@ -1,11 +1,13 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod"; import { z } from "zod";
import speakeasy from "speakeasy"; import speakeasy from "speakeasy";
import QRCode from "qrcode"; import QRCode from "qrcode";
import crypto from "crypto"; import crypto from "crypto";
import { requireAuth, requireAdmin, generateToken } from "../middleware/auth"; import jwt from "jsonwebtoken";
import { requireAuth, requireAdmin, generateToken, generateRefreshToken } from "../middleware/auth";
import { encrypt, decrypt } from "../utils/encryption"; import { encrypt, decrypt } from "../utils/encryption";
const router = Router(); const router = Router();
@@ -119,15 +121,21 @@ router.post("/login", async (req, res) => {
} }
} }
// Generate JWT token // Generate JWT tokens
const jwtToken = generateToken({ const jwtToken = generateToken({
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
tokenVersion: user.tokenVersion,
});
const refreshToken = generateRefreshToken({
id: user.id,
tokenVersion: user.tokenVersion,
}); });
res.json({ res.json({
token: jwtToken, token: jwtToken,
refreshToken: refreshToken,
user: { user: {
id: user.id, id: user.id,
username: user.username, username: user.username,
@@ -138,7 +146,7 @@ router.post("/login", async (req, res) => {
if (err instanceof z.ZodError) { if (err instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid request", details: err.errors }); return res.status(400).json({ error: "Invalid request", details: err.errors });
} }
console.error("Login error:", err); logger.error("Login error:", err);
res.status(500).json({ error: "Internal error" }); res.status(500).json({ error: "Internal error" });
} }
}); });
@@ -150,6 +158,47 @@ router.post("/logout", (req, res) => {
res.json({ message: "Logged out" }); res.json({ message: "Logged out" });
}); });
// POST /auth/refresh - Refresh access token using refresh token
router.post("/refresh", async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: "Refresh token required" });
}
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_SECRET || process.env.SESSION_SECRET!) as any;
if (decoded.type !== "refresh") {
return res.status(401).json({ error: "Invalid refresh token" });
}
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true, tokenVersion: true }
});
if (!user) {
return res.status(401).json({ error: "User not found" });
}
// Validate tokenVersion
if (decoded.tokenVersion !== user.tokenVersion) {
return res.status(401).json({ error: "Token invalidated" });
}
const newAccessToken = generateToken(user);
const newRefreshToken = generateRefreshToken(user);
return res.json({
token: newAccessToken,
refreshToken: newRefreshToken
});
} catch (error) {
return res.status(401).json({ error: "Invalid refresh token" });
}
});
/** /**
* @openapi * @openapi
* /auth/me: * /auth/me:
@@ -226,16 +275,19 @@ router.post("/change-password", requireAuth, async (req, res) => {
.json({ error: "Current password is incorrect" }); .json({ error: "Current password is incorrect" });
} }
// Update password // Update password and increment tokenVersion to invalidate all existing tokens
const newPasswordHash = await bcrypt.hash(newPassword, 10); const newPasswordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({ await prisma.user.update({
where: { id: req.user!.id }, where: { id: req.user!.id },
data: { passwordHash: newPasswordHash }, data: {
passwordHash: newPasswordHash,
tokenVersion: { increment: 1 }
},
}); });
res.json({ message: "Password changed successfully" }); res.json({ message: "Password changed successfully" });
} catch (error) { } catch (error) {
console.error("Change password error:", error); logger.error("Change password error:", error);
res.status(500).json({ error: "Failed to change password" }); res.status(500).json({ error: "Failed to change password" });
} }
}); });
@@ -256,7 +308,7 @@ router.get("/users", requireAuth, requireAdmin, async (req, res) => {
res.json(users); res.json(users);
} catch (error) { } catch (error) {
console.error("Get users error:", error); logger.error("Get users error:", error);
res.status(500).json({ error: "Failed to get users" }); res.status(500).json({ error: "Failed to get users" });
} }
}); });
@@ -320,7 +372,7 @@ router.post("/create-user", requireAuth, requireAdmin, async (req, res) => {
createdAt: user.createdAt, createdAt: user.createdAt,
}); });
} catch (error) { } catch (error) {
console.error("Create user error:", error); logger.error("Create user error:", error);
res.status(500).json({ error: "Failed to create user" }); res.status(500).json({ error: "Failed to create user" });
} }
}); });
@@ -344,7 +396,7 @@ router.delete("/users/:id", requireAuth, requireAdmin, async (req, res) => {
res.json({ message: "User deleted successfully" }); res.json({ message: "User deleted successfully" });
} catch (error: any) { } catch (error: any) {
console.error("Delete user error:", error); logger.error("Delete user error:", error);
if (error.code === "P2025") { if (error.code === "P2025") {
return res.status(404).json({ error: "User not found" }); return res.status(404).json({ error: "User not found" });
} }
@@ -382,7 +434,7 @@ router.post("/2fa/setup", requireAuth, async (req, res) => {
qrCode: qrCodeDataUrl, qrCode: qrCodeDataUrl,
}); });
} catch (error) { } catch (error) {
console.error("2FA setup error:", error); logger.error("2FA setup error:", error);
res.status(500).json({ error: "Failed to setup 2FA" }); res.status(500).json({ error: "Failed to setup 2FA" });
} }
}); });
@@ -448,7 +500,7 @@ router.post("/2fa/enable", requireAuth, async (req, res) => {
recoveryCodes: recoveryCodes, recoveryCodes: recoveryCodes,
}); });
} catch (error) { } catch (error) {
console.error("2FA enable error:", error); logger.error("2FA enable error:", error);
res.status(500).json({ error: "Failed to enable 2FA" }); res.status(500).json({ error: "Failed to enable 2FA" });
} }
}); });
@@ -505,7 +557,7 @@ router.post("/2fa/disable", requireAuth, async (req, res) => {
res.json({ message: "2FA disabled successfully" }); res.json({ message: "2FA disabled successfully" });
} catch (error) { } catch (error) {
console.error("2FA disable error:", error); logger.error("2FA disable error:", error);
res.status(500).json({ error: "Failed to disable 2FA" }); res.status(500).json({ error: "Failed to disable 2FA" });
} }
}); });
@@ -524,7 +576,7 @@ router.get("/2fa/status", requireAuth, async (req, res) => {
res.json({ enabled: user.twoFactorEnabled }); res.json({ enabled: user.twoFactorEnabled });
} catch (error) { } catch (error) {
console.error("2FA status error:", error); logger.error("2FA status error:", error);
res.status(500).json({ error: "Failed to get 2FA status" }); res.status(500).json({ error: "Failed to get 2FA status" });
} }
}); });
+22 -21
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { spotifyService } from "../services/spotify"; import { spotifyService } from "../services/spotify";
import { deezerService, DeezerPlaylistPreview, DeezerRadioStation } from "../services/deezer"; import { deezerService, DeezerPlaylistPreview, DeezerRadioStation } from "../services/deezer";
@@ -68,10 +69,10 @@ function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview {
router.get("/playlists/featured", async (req, res) => { router.get("/playlists/featured", async (req, res) => {
try { try {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
console.log(`[Browse] Fetching featured playlists (limit: ${limit})...`); logger.debug(`[Browse] Fetching featured playlists (limit: ${limit})...`);
const playlists = await deezerService.getFeaturedPlaylists(limit); const playlists = await deezerService.getFeaturedPlaylists(limit);
console.log(`[Browse] Got ${playlists.length} Deezer playlists`); logger.debug(`[Browse] Got ${playlists.length} Deezer playlists`);
res.json({ res.json({
playlists: playlists.map(deezerPlaylistToUnified), playlists: playlists.map(deezerPlaylistToUnified),
@@ -79,7 +80,7 @@ router.get("/playlists/featured", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Browse featured playlists error:", error); logger.error("Browse featured playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlists" }); res.status(500).json({ error: error.message || "Failed to fetch playlists" });
} }
}); });
@@ -96,10 +97,10 @@ router.get("/playlists/search", async (req, res) => {
} }
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
console.log(`[Browse] Searching playlists for "${query}"...`); logger.debug(`[Browse] Searching playlists for "${query}"...`);
const playlists = await deezerService.searchPlaylists(query, limit); const playlists = await deezerService.searchPlaylists(query, limit);
console.log(`[Browse] Search "${query}": ${playlists.length} results`); logger.debug(`[Browse] Search "${query}": ${playlists.length} results`);
res.json({ res.json({
playlists: playlists.map(deezerPlaylistToUnified), playlists: playlists.map(deezerPlaylistToUnified),
@@ -108,7 +109,7 @@ router.get("/playlists/search", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Browse search playlists error:", error); logger.error("Browse search playlists error:", error);
res.status(500).json({ error: error.message || "Failed to search playlists" }); res.status(500).json({ error: error.message || "Failed to search playlists" });
} }
}); });
@@ -132,7 +133,7 @@ router.get("/playlists/:id", async (req, res) => {
url: `https://www.deezer.com/playlist/${id}`, url: `https://www.deezer.com/playlist/${id}`,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Playlist fetch error:", error); logger.error("Playlist fetch error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlist" }); res.status(500).json({ error: error.message || "Failed to fetch playlist" });
} }
}); });
@@ -147,7 +148,7 @@ router.get("/playlists/:id", async (req, res) => {
*/ */
router.get("/radios", async (req, res) => { router.get("/radios", async (req, res) => {
try { try {
console.log("[Browse] Fetching radio stations..."); logger.debug("[Browse] Fetching radio stations...");
const radios = await deezerService.getRadioStations(); const radios = await deezerService.getRadioStations();
res.json({ res.json({
@@ -156,7 +157,7 @@ router.get("/radios", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Browse radios error:", error); logger.error("Browse radios error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" }); res.status(500).json({ error: error.message || "Failed to fetch radios" });
} }
}); });
@@ -167,7 +168,7 @@ router.get("/radios", async (req, res) => {
*/ */
router.get("/radios/by-genre", async (req, res) => { router.get("/radios/by-genre", async (req, res) => {
try { try {
console.log("[Browse] Fetching radios by genre..."); logger.debug("[Browse] Fetching radios by genre...");
const genresWithRadios = await deezerService.getRadiosByGenre(); const genresWithRadios = await deezerService.getRadiosByGenre();
// Transform to include unified format // Transform to include unified format
@@ -183,7 +184,7 @@ router.get("/radios/by-genre", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Browse radios by genre error:", error); logger.error("Browse radios by genre error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" }); res.status(500).json({ error: error.message || "Failed to fetch radios" });
} }
}); });
@@ -195,7 +196,7 @@ router.get("/radios/by-genre", async (req, res) => {
router.get("/radios/:id", async (req, res) => { router.get("/radios/:id", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
console.log(`[Browse] Fetching radio ${id} tracks...`); logger.debug(`[Browse] Fetching radio ${id} tracks...`);
const radioPlaylist = await deezerService.getRadioTracks(id); const radioPlaylist = await deezerService.getRadioTracks(id);
@@ -209,7 +210,7 @@ router.get("/radios/:id", async (req, res) => {
type: "radio", type: "radio",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Radio tracks error:", error); logger.error("Radio tracks error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radio tracks" }); res.status(500).json({ error: error.message || "Failed to fetch radio tracks" });
} }
}); });
@@ -224,7 +225,7 @@ router.get("/radios/:id", async (req, res) => {
*/ */
router.get("/genres", async (req, res) => { router.get("/genres", async (req, res) => {
try { try {
console.log("[Browse] Fetching genres..."); logger.debug("[Browse] Fetching genres...");
const genres = await deezerService.getGenres(); const genres = await deezerService.getGenres();
res.json({ res.json({
@@ -233,7 +234,7 @@ router.get("/genres", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Browse genres error:", error); logger.error("Browse genres error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genres" }); res.status(500).json({ error: error.message || "Failed to fetch genres" });
} }
}); });
@@ -249,7 +250,7 @@ router.get("/genres/:id", async (req, res) => {
return res.status(400).json({ error: "Invalid genre ID" }); return res.status(400).json({ error: "Invalid genre ID" });
} }
console.log(`[Browse] Fetching content for genre ${genreId}...`); logger.debug(`[Browse] Fetching content for genre ${genreId}...`);
const content = await deezerService.getEditorialContent(genreId); const content = await deezerService.getEditorialContent(genreId);
res.json({ res.json({
@@ -259,7 +260,7 @@ router.get("/genres/:id", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Genre content error:", error); logger.error("Genre content error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre content" }); res.status(500).json({ error: error.message || "Failed to fetch genre content" });
} }
}); });
@@ -290,7 +291,7 @@ router.get("/genres/:id/playlists", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Genre playlists error:", error); logger.error("Genre playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre playlists" }); res.status(500).json({ error: error.message || "Failed to fetch genre playlists" });
} }
}); });
@@ -337,7 +338,7 @@ router.post("/playlists/parse", async (req, res) => {
error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL." error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL."
}); });
} catch (error: any) { } catch (error: any) {
console.error("Parse URL error:", error); logger.error("Parse URL error:", error);
res.status(500).json({ error: error.message || "Failed to parse URL" }); res.status(500).json({ error: error.message || "Failed to parse URL" });
} }
}); });
@@ -353,7 +354,7 @@ router.post("/playlists/parse", async (req, res) => {
*/ */
router.get("/all", async (req, res) => { router.get("/all", async (req, res) => {
try { try {
console.log("[Browse] Fetching browse content (playlists + genres)..."); logger.debug("[Browse] Fetching browse content (playlists + genres)...");
// Only fetch playlists and genres - radios are now internal library-based // Only fetch playlists and genres - radios are now internal library-based
const [playlists, genres] = await Promise.all([ const [playlists, genres] = await Promise.all([
@@ -369,7 +370,7 @@ router.get("/all", async (req, res) => {
source: "deezer", source: "deezer",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Browse all error:", error); logger.error("Browse all error:", error);
res.status(500).json({ error: error.message || "Failed to fetch browse content" }); res.status(500).json({ error: error.message || "Failed to fetch browse content" });
} }
}); });
+6 -5
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import crypto from "crypto"; import crypto from "crypto";
@@ -64,7 +65,7 @@ router.post("/generate", requireAuthOrToken, async (req, res) => {
expiresIn: 300, // 5 minutes in seconds expiresIn: 300, // 5 minutes in seconds
}); });
} catch (error) { } catch (error) {
console.error("Generate device link code error:", error); logger.error("Generate device link code error:", error);
res.status(500).json({ error: "Failed to generate device link code" }); res.status(500).json({ error: "Failed to generate device link code" });
} }
}); });
@@ -123,7 +124,7 @@ router.post("/verify", async (req, res) => {
username: linkCode.user.username, username: linkCode.user.username,
}); });
} catch (error) { } catch (error) {
console.error("Verify device link code error:", error); logger.error("Verify device link code error:", error);
res.status(500).json({ error: "Failed to verify device link code" }); res.status(500).json({ error: "Failed to verify device link code" });
} }
}); });
@@ -161,7 +162,7 @@ router.get("/status/:code", async (req, res) => {
expiresAt: linkCode.expiresAt, expiresAt: linkCode.expiresAt,
}); });
} catch (error) { } catch (error) {
console.error("Check device link status error:", error); logger.error("Check device link status error:", error);
res.status(500).json({ error: "Failed to check status" }); res.status(500).json({ error: "Failed to check status" });
} }
}); });
@@ -184,7 +185,7 @@ router.get("/devices", requireAuthOrToken, async (req, res) => {
res.json(apiKeys); res.json(apiKeys);
} catch (error) { } catch (error) {
console.error("Get devices error:", error); logger.error("Get devices error:", error);
res.status(500).json({ error: "Failed to get devices" }); res.status(500).json({ error: "Failed to get devices" });
} }
}); });
@@ -209,7 +210,7 @@ router.delete("/devices/:id", requireAuthOrToken, async (req, res) => {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Revoke device error:", error); logger.error("Revoke device error:", error);
res.status(500).json({ error: "Failed to revoke device" }); res.status(500).json({ error: "Failed to revoke device" });
} }
}); });
File diff suppressed because it is too large Load Diff
+232 -65
View File
@@ -1,9 +1,11 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { config } from "../config"; import { config } from "../config";
import { lidarrService } from "../services/lidarr"; import { lidarrService } from "../services/lidarr";
import { musicBrainzService } from "../services/musicbrainz"; import { musicBrainzService } from "../services/musicbrainz";
import { lastFmService } from "../services/lastfm";
import { simpleDownloadManager } from "../services/simpleDownloadManager"; import { simpleDownloadManager } from "../services/simpleDownloadManager";
import crypto from "crypto"; import crypto from "crypto";
@@ -11,6 +13,78 @@ const router = Router();
router.use(requireAuthOrToken); router.use(requireAuthOrToken);
/**
* Verify and potentially correct artist name before download
* Uses multiple sources for canonical name resolution:
* 1. MusicBrainz (if MBID provided) - most authoritative
* 2. LastFM correction API - handles aliases and misspellings
* 3. Original name - fallback
*
* @returns Object with verified name and whether correction was applied
*/
async function verifyArtistName(
artistName: string,
artistMbid?: string
): Promise<{
verifiedName: string;
wasCorrected: boolean;
source: "musicbrainz" | "lastfm" | "original";
originalName: string;
}> {
const originalName = artistName;
// Strategy 1: If we have MBID, use MusicBrainz as authoritative source
if (artistMbid) {
try {
const mbArtist = await musicBrainzService.getArtist(artistMbid);
if (mbArtist?.name) {
return {
verifiedName: mbArtist.name,
wasCorrected:
mbArtist.name.toLowerCase() !==
artistName.toLowerCase(),
source: "musicbrainz",
originalName,
};
}
} catch (error) {
logger.warn(
`MusicBrainz lookup failed for MBID ${artistMbid}:`,
error
);
}
}
// Strategy 2: Use LastFM correction API
try {
const correction = await lastFmService.getArtistCorrection(artistName);
if (correction?.corrected) {
logger.debug(
`[VERIFY] LastFM correction: "${artistName}" → "${correction.canonicalName}"`
);
return {
verifiedName: correction.canonicalName,
wasCorrected: true,
source: "lastfm",
originalName,
};
}
} catch (error) {
logger.warn(
`LastFM correction lookup failed for "${artistName}":`,
error
);
}
// Strategy 3: Return original name
return {
verifiedName: artistName,
wasCorrected: false,
source: "original",
originalName,
};
}
// POST /downloads - Create download job // POST /downloads - Create download job
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
try { try {
@@ -75,6 +149,18 @@ router.post("/", async (req, res) => {
}); });
} }
// Single album download - verify artist name before proceeding
let verifiedArtistName = artistName;
if (type === "album" && artistName) {
const verification = await verifyArtistName(artistName, mbid);
if (verification.wasCorrected) {
logger.debug(
`[DOWNLOAD] Artist name verified: "${artistName}" → "${verification.verifiedName}" (source: ${verification.source})`
);
verifiedArtistName = verification.verifiedName;
}
}
// Single album download - check for existing job first // Single album download - check for existing job first
const existingJob = await prisma.downloadJob.findFirst({ const existingJob = await prisma.downloadJob.findFirst({
where: { where: {
@@ -84,7 +170,9 @@ router.post("/", async (req, res) => {
}); });
if (existingJob) { if (existingJob) {
console.log(`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`); logger.debug(
`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`
);
return res.json({ return res.json({
id: existingJob.id, id: existingJob.id,
status: existingJob.status, status: existingJob.status,
@@ -105,13 +193,13 @@ router.post("/", async (req, res) => {
metadata: { metadata: {
downloadType, downloadType,
rootFolderPath, rootFolderPath,
artistName, artistName: verifiedArtistName,
albumTitle, albumTitle,
}, },
}, },
}); });
console.log( logger.debug(
`[DOWNLOAD] Triggering Lidarr: ${type} "${subject}" -> ${rootFolderPath}` `[DOWNLOAD] Triggering Lidarr: ${type} "${subject}" -> ${rootFolderPath}`
); );
@@ -122,10 +210,10 @@ router.post("/", async (req, res) => {
mbid, mbid,
subject, subject,
rootFolderPath, rootFolderPath,
artistName, verifiedArtistName,
albumTitle albumTitle
).catch((error) => { ).catch((error) => {
console.error( logger.error(
`Download processing failed for job ${job.id}:`, `Download processing failed for job ${job.id}:`,
error error
); );
@@ -139,7 +227,7 @@ router.post("/", async (req, res) => {
message: "Download job created. Processing in background.", message: "Download job created. Processing in background.",
}); });
} catch (error) { } catch (error) {
console.error("Create download job error:", error); logger.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" }); res.status(500).json({ error: "Failed to create download job" });
} }
}); });
@@ -154,27 +242,66 @@ async function processArtistDownload(
rootFolderPath: string, rootFolderPath: string,
downloadType: string downloadType: string
): Promise<{ id: string; subject: string }[]> { ): Promise<{ id: string; subject: string }[]> {
console.log(`\n Processing artist download: ${artistName}`); logger.debug(`\n Processing artist download: ${artistName}`);
console.log(` Artist MBID: ${artistMbid}`); logger.debug(` Artist MBID: ${artistMbid}`);
// Generate a batch ID to group all album downloads // Generate a batch ID to group all album downloads
const batchId = crypto.randomUUID(); const batchId = crypto.randomUUID();
console.log(` Batch ID: ${batchId}`); logger.debug(` Batch ID: ${batchId}`);
// CRITICAL FIX: Resolve canonical artist name from MusicBrainz
// Last.fm may return aliases (e.g., "blink" for "blink-182")
// Lidarr needs the official name to find the correct artist
let canonicalArtistName = artistName;
try {
logger.debug(` Resolving canonical artist name from MusicBrainz...`);
const mbArtist = await musicBrainzService.getArtist(artistMbid);
if (mbArtist && mbArtist.name) {
canonicalArtistName = mbArtist.name;
if (canonicalArtistName !== artistName) {
logger.debug(
` ✓ Canonical name resolved: "${artistName}" → "${canonicalArtistName}"`
);
} else {
logger.debug(
` ✓ Name matches canonical: "${canonicalArtistName}"`
);
}
}
} catch (mbError: any) {
logger.warn(` ⚠ MusicBrainz lookup failed: ${mbError.message}`);
// Fallback to LastFM correction
try {
const correction = await lastFmService.getArtistCorrection(
artistName
);
if (correction?.canonicalName) {
canonicalArtistName = correction.canonicalName;
logger.debug(
` ✓ Name resolved via LastFM: "${artistName}" → "${canonicalArtistName}"`
);
}
} catch (lfmError) {
logger.warn(
` ⚠ LastFM correction also failed, using original name`
);
}
}
try { try {
// First, add the artist to Lidarr (this monitors all albums) // First, add the artist to Lidarr (this monitors all albums)
const lidarrArtist = await lidarrService.addArtist( const lidarrArtist = await lidarrService.addArtist(
artistMbid, artistMbid,
artistName, canonicalArtistName,
rootFolderPath rootFolderPath
); );
if (!lidarrArtist) { if (!lidarrArtist) {
console.log(` Failed to add artist to Lidarr`); logger.debug(` Failed to add artist to Lidarr`);
throw new Error("Failed to add artist to Lidarr"); throw new Error("Failed to add artist to Lidarr");
} }
console.log(` Artist added to Lidarr (ID: ${lidarrArtist.id})`); logger.debug(` Artist added to Lidarr (ID: ${lidarrArtist.id})`);
// Fetch albums from MusicBrainz // Fetch albums from MusicBrainz
const releaseGroups = await musicBrainzService.getReleaseGroups( const releaseGroups = await musicBrainzService.getReleaseGroups(
@@ -183,12 +310,12 @@ async function processArtistDownload(
100 100
); );
console.log( logger.debug(
` Found ${releaseGroups.length} albums/EPs from MusicBrainz` ` Found ${releaseGroups.length} albums/EPs from MusicBrainz`
); );
if (releaseGroups.length === 0) { if (releaseGroups.length === 0) {
console.log(` No albums found for artist`); logger.debug(` No albums found for artist`);
return []; return [];
} }
@@ -206,49 +333,84 @@ async function processArtistDownload(
}); });
if (existingAlbum) { if (existingAlbum) {
console.log(` Skipping "${albumTitle}" - already in library`); logger.debug(` Skipping "${albumTitle}" - already in library`);
continue; continue;
} }
// Check if there's already a pending/processing job for this album // Use transaction to prevent race conditions when creating jobs
const existingJob = await prisma.downloadJob.findFirst({ const jobResult = await prisma.$transaction(async (tx) => {
where: { // Check for existing active job
targetMbid: albumMbid, const existingJob = await tx.downloadJob.findFirst({
status: { in: ["pending", "processing"] }, where: {
}, targetMbid: albumMbid,
status: { in: ["pending", "processing"] },
},
});
if (existingJob) {
return {
skipped: true,
job: existingJob,
reason: "already_queued",
};
}
// Also check for recently failed job (within last 30 seconds) to prevent spam retries
const recentFailed = await tx.downloadJob.findFirst({
where: {
targetMbid: albumMbid,
status: "failed",
completedAt: { gte: new Date(Date.now() - 30000) },
},
});
if (recentFailed) {
return {
skipped: true,
job: recentFailed,
reason: "recently_failed",
};
}
// Create new job inside transaction
const now = new Date();
const job = await tx.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
},
},
});
return { skipped: false, job };
}); });
if (existingJob) { if (jobResult.skipped) {
console.log( logger.debug(
` Skipping "${albumTitle}" - already in download queue` ` Skipping "${albumTitle}" - ${
jobResult.reason === "recently_failed"
? "recently failed"
: "already in download queue"
}`
); );
continue; continue;
} }
// Create download job for this album const job = jobResult.job;
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 }); jobs.push({ id: job.id, subject: albumSubject });
console.log(` [JOB] Created job for: ${albumSubject}`); logger.debug(` [JOB] Created job for: ${albumSubject}`);
// Start the download in background // Start the download in background
processDownload( processDownload(
@@ -260,14 +422,14 @@ async function processArtistDownload(
artistName, artistName,
albumTitle albumTitle
).catch((error) => { ).catch((error) => {
console.error(`Download failed for ${albumSubject}:`, error); logger.error(`Download failed for ${albumSubject}:`, error);
}); });
} }
console.log(` Created ${jobs.length} album download jobs`); logger.debug(` Created ${jobs.length} album download jobs`);
return jobs; return jobs;
} catch (error: any) { } catch (error: any) {
console.error(` Failed to process artist download:`, error.message); logger.error(` Failed to process artist download:`, error.message);
throw error; throw error;
} }
} }
@@ -284,7 +446,7 @@ async function processDownload(
) { ) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } }); const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) { if (!job) {
console.error(`Job ${jobId} not found`); logger.error(`Job ${jobId} not found`);
return; return;
} }
@@ -304,7 +466,7 @@ async function processDownload(
} }
} }
console.log(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`); logger.debug(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`);
// Use simple download manager for album downloads // Use simple download manager for album downloads
const result = await simpleDownloadManager.startDownload( const result = await simpleDownloadManager.startDownload(
@@ -316,7 +478,7 @@ async function processDownload(
); );
if (!result.success) { if (!result.success) {
console.error(`Failed to start download: ${result.error}`); logger.error(`Failed to start download: ${result.error}`);
} }
} }
} }
@@ -335,12 +497,12 @@ router.delete("/clear-all", async (req, res) => {
const result = await prisma.downloadJob.deleteMany({ where }); const result = await prisma.downloadJob.deleteMany({ where });
console.log( logger.debug(
` Cleared ${result.count} download jobs for user ${userId}` ` Cleared ${result.count} download jobs for user ${userId}`
); );
res.json({ success: true, deleted: result.count }); res.json({ success: true, deleted: result.count });
} catch (error) { } catch (error) {
console.error("Clear downloads error:", error); logger.error("Clear downloads error:", error);
res.status(500).json({ error: "Failed to clear downloads" }); res.status(500).json({ error: "Failed to clear downloads" });
} }
}); });
@@ -355,7 +517,7 @@ router.post("/clear-lidarr-queue", async (req, res) => {
errors: result.errors, errors: result.errors,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Clear Lidarr queue error:", error); logger.error("Clear Lidarr queue error:", error);
res.status(500).json({ error: "Failed to clear Lidarr queue" }); res.status(500).json({ error: "Failed to clear Lidarr queue" });
} }
}); });
@@ -373,7 +535,7 @@ router.get("/failed", async (req, res) => {
res.json(failedAlbums); res.json(failedAlbums);
} catch (error) { } catch (error) {
console.error("List failed albums error:", error); logger.error("List failed albums error:", error);
res.status(500).json({ error: "Failed to list failed albums" }); res.status(500).json({ error: "Failed to list failed albums" });
} }
}); });
@@ -399,7 +561,7 @@ router.delete("/failed/:id", async (req, res) => {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Delete failed album error:", error); logger.error("Delete failed album error:", error);
res.status(500).json({ error: "Failed to delete failed album" }); res.status(500).json({ error: "Failed to delete failed album" });
} }
}); });
@@ -423,7 +585,7 @@ router.get("/:id", async (req, res) => {
res.json(job); res.json(job);
} catch (error) { } catch (error) {
console.error("Get download job error:", error); logger.error("Get download job error:", error);
res.status(500).json({ error: "Failed to get download job" }); res.status(500).json({ error: "Failed to get download job" });
} }
}); });
@@ -456,7 +618,7 @@ router.patch("/:id", async (req, res) => {
res.json(updated); res.json(updated);
} catch (error) { } catch (error) {
console.error("Update download job error:", error); logger.error("Update download job error:", error);
res.status(500).json({ error: "Failed to update download job" }); res.status(500).json({ error: "Failed to update download job" });
} }
}); });
@@ -479,8 +641,8 @@ router.delete("/:id", async (req, res) => {
// Return success even if nothing was deleted (idempotent delete) // Return success even if nothing was deleted (idempotent delete)
res.json({ success: true, deleted: result.count > 0 }); res.json({ success: true, deleted: result.count > 0 });
} catch (error: any) { } catch (error: any) {
console.error("Delete download job error:", error); logger.error("Delete download job error:", error);
console.error("Error details:", error.message, error.stack); logger.error("Error details:", error.message, error.stack);
res.status(500).json({ res.status(500).json({
error: "Failed to delete download job", error: "Failed to delete download job",
details: error.message, details: error.message,
@@ -492,7 +654,12 @@ router.delete("/:id", async (req, res) => {
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try { try {
const userId = req.user!.id; const userId = req.user!.id;
const { status, limit = "50", includeDiscovery = "false", includeCleared = "false" } = req.query; const {
status,
limit = "50",
includeDiscovery = "false",
includeCleared = "false",
} = req.query;
const where: any = { userId }; const where: any = { userId };
if (status) { if (status) {
@@ -521,7 +688,7 @@ router.get("/", async (req, res) => {
res.json(filteredJobs); res.json(filteredJobs);
} catch (error) { } catch (error) {
console.error("List download jobs error:", error); logger.error("List download jobs error:", error);
res.status(500).json({ error: "Failed to list download jobs" }); res.status(500).json({ error: "Failed to list download jobs" });
} }
}); });
@@ -580,7 +747,7 @@ router.post("/keep-track", async (req, res) => {
"Track marked as kept. Please add the full album manually to your /music folder.", "Track marked as kept. Please add the full album manually to your /music folder.",
}); });
} catch (error) { } catch (error) {
console.error("Keep track error:", error); logger.error("Keep track error:", error);
res.status(500).json({ error: "Failed to keep track" }); res.status(500).json({ error: "Failed to keep track" });
} }
}); });
+794 -62
View File
@@ -1,7 +1,19 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAdmin } from "../middleware/auth"; import { requireAuth, requireAdmin } from "../middleware/auth";
import { enrichmentService } from "../services/enrichment"; import { enrichmentService } from "../services/enrichment";
import { getEnrichmentProgress, runFullEnrichment } from "../workers/unifiedEnrichment"; import {
getEnrichmentProgress,
runFullEnrichment,
} from "../workers/unifiedEnrichment";
import { enrichmentStateService } from "../services/enrichmentState";
import { enrichmentFailureService } from "../services/enrichmentFailureService";
import {
getSystemSettings,
invalidateSystemSettingsCache,
} from "../utils/systemSettings";
import { rateLimiter } from "../services/rateLimiter";
import { redisClient } from "../utils/redis";
const router = Router(); const router = Router();
@@ -16,11 +28,82 @@ router.get("/progress", async (req, res) => {
const progress = await getEnrichmentProgress(); const progress = await getEnrichmentProgress();
res.json(progress); res.json(progress);
} catch (error) { } catch (error) {
console.error("Get enrichment progress error:", error); logger.error("Get enrichment progress error:", error);
res.status(500).json({ error: "Failed to get progress" }); res.status(500).json({ error: "Failed to get progress" });
} }
}); });
/**
* GET /enrichment/status
* Get detailed enrichment state (running, paused, etc.)
*/
router.get("/status", async (req, res) => {
try {
const state = await enrichmentStateService.getState();
res.json(state || { status: "idle", currentPhase: null });
} catch (error) {
logger.error("Get enrichment status error:", error);
res.status(500).json({ error: "Failed to get status" });
}
});
/**
* POST /enrichment/pause
* Pause the enrichment process
*/
router.post("/pause", requireAdmin, async (req, res) => {
try {
const state = await enrichmentStateService.pause();
res.json({
message: "Enrichment paused",
state,
});
} catch (error: any) {
logger.error("Pause enrichment error:", error);
res.status(400).json({
error: error.message || "Failed to pause enrichment",
});
}
});
/**
* POST /enrichment/resume
* Resume a paused enrichment process
*/
router.post("/resume", requireAdmin, async (req, res) => {
try {
const state = await enrichmentStateService.resume();
res.json({
message: "Enrichment resumed",
state,
});
} catch (error: any) {
logger.error("Resume enrichment error:", error);
res.status(400).json({
error: error.message || "Failed to resume enrichment",
});
}
});
/**
* POST /enrichment/stop
* Stop the enrichment process
*/
router.post("/stop", requireAdmin, async (req, res) => {
try {
const state = await enrichmentStateService.stop();
res.json({
message: "Enrichment stopping...",
state,
});
} catch (error: any) {
logger.error("Stop enrichment error:", error);
res.status(400).json({
error: error.message || "Failed to stop enrichment",
});
}
});
/** /**
* POST /enrichment/full * POST /enrichment/full
* Trigger full enrichment (re-enriches everything regardless of status) * Trigger full enrichment (re-enriches everything regardless of status)
@@ -29,20 +112,48 @@ router.get("/progress", async (req, res) => {
router.post("/full", requireAdmin, async (req, res) => { router.post("/full", requireAdmin, async (req, res) => {
try { try {
// This runs in the background // This runs in the background
runFullEnrichment().catch(err => { runFullEnrichment().catch((err) => {
console.error("Full enrichment error:", err); logger.error("Full enrichment error:", err);
}); });
res.json({ res.json({
message: "Full enrichment started", message: "Full enrichment started",
description: "All artists, track tags, and audio analysis will be re-processed" description:
"All artists, track tags, and audio analysis will be re-processed",
}); });
} catch (error) { } catch (error) {
console.error("Trigger full enrichment error:", error); logger.error("Trigger full enrichment error:", error);
res.status(500).json({ error: "Failed to start full enrichment" }); res.status(500).json({ error: "Failed to start full enrichment" });
} }
}); });
/**
* POST /enrichment/sync
* Trigger incremental enrichment (only processes pending items)
* Fast sync that picks up new content without re-processing everything
*/
router.post("/sync", async (req, res) => {
try {
const { triggerEnrichmentNow } = await import(
"../workers/unifiedEnrichment"
);
// Trigger immediate enrichment cycle (incremental mode)
const result = await triggerEnrichmentNow();
res.json({
message: "Incremental sync started",
description: "Processing new and pending items only",
result,
});
} catch (error: any) {
logger.error("Trigger sync error:", error);
res.status(500).json({
error: error.message || "Failed to start sync",
});
}
});
/** /**
* GET /enrichment/settings * GET /enrichment/settings
* Get enrichment settings for current user * Get enrichment settings for current user
@@ -53,7 +164,7 @@ router.get("/settings", async (req, res) => {
const settings = await enrichmentService.getSettings(userId); const settings = await enrichmentService.getSettings(userId);
res.json(settings); res.json(settings);
} catch (error) { } catch (error) {
console.error("Get enrichment settings error:", error); logger.error("Get enrichment settings error:", error);
res.status(500).json({ error: "Failed to get settings" }); res.status(500).json({ error: "Failed to get settings" });
} }
}); });
@@ -65,10 +176,13 @@ router.get("/settings", async (req, res) => {
router.put("/settings", async (req, res) => { router.put("/settings", async (req, res) => {
try { try {
const userId = req.user!.id; const userId = req.user!.id;
const settings = await enrichmentService.updateSettings(userId, req.body); const settings = await enrichmentService.updateSettings(
userId,
req.body
);
res.json(settings); res.json(settings);
} catch (error) { } catch (error) {
console.error("Update enrichment settings error:", error); logger.error("Update enrichment settings error:", error);
res.status(500).json({ error: "Failed to update settings" }); res.status(500).json({ error: "Failed to update settings" });
} }
}); });
@@ -86,14 +200,20 @@ router.post("/artist/:id", async (req, res) => {
return res.status(400).json({ error: "Enrichment is not enabled" }); return res.status(400).json({ error: "Enrichment is not enabled" });
} }
const enrichmentData = await enrichmentService.enrichArtist(req.params.id, settings); const enrichmentData = await enrichmentService.enrichArtist(
req.params.id,
settings
);
if (!enrichmentData) { if (!enrichmentData) {
return res.status(404).json({ error: "No enrichment data found" }); return res.status(404).json({ error: "No enrichment data found" });
} }
if (enrichmentData.confidence > 0.3) { if (enrichmentData.confidence > 0.3) {
await enrichmentService.applyArtistEnrichment(req.params.id, enrichmentData); await enrichmentService.applyArtistEnrichment(
req.params.id,
enrichmentData
);
} }
res.json({ res.json({
@@ -102,8 +222,10 @@ router.post("/artist/:id", async (req, res) => {
data: enrichmentData, data: enrichmentData,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Enrich artist error:", error); logger.error("Enrich artist error:", error);
res.status(500).json({ error: error.message || "Failed to enrich artist" }); res.status(500).json({
error: error.message || "Failed to enrich artist",
});
} }
}); });
@@ -120,14 +242,20 @@ router.post("/album/:id", async (req, res) => {
return res.status(400).json({ error: "Enrichment is not enabled" }); return res.status(400).json({ error: "Enrichment is not enabled" });
} }
const enrichmentData = await enrichmentService.enrichAlbum(req.params.id, settings); const enrichmentData = await enrichmentService.enrichAlbum(
req.params.id,
settings
);
if (!enrichmentData) { if (!enrichmentData) {
return res.status(404).json({ error: "No enrichment data found" }); return res.status(404).json({ error: "No enrichment data found" });
} }
if (enrichmentData.confidence > 0.3) { if (enrichmentData.confidence > 0.3) {
await enrichmentService.applyAlbumEnrichment(req.params.id, enrichmentData); await enrichmentService.applyAlbumEnrichment(
req.params.id,
enrichmentData
);
} }
res.json({ res.json({
@@ -136,8 +264,10 @@ router.post("/album/:id", async (req, res) => {
data: enrichmentData, data: enrichmentData,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Enrich album error:", error); logger.error("Enrich album error:", error);
res.status(500).json({ error: error.message || "Failed to enrich album" }); res.status(500).json({
error: error.message || "Failed to enrich album",
});
} }
}); });
@@ -148,7 +278,9 @@ router.post("/album/:id", async (req, res) => {
router.post("/start", async (req, res) => { router.post("/start", async (req, res) => {
try { try {
const userId = req.user!.id; const userId = req.user!.id;
const { notificationService } = await import("../services/notificationService"); const { notificationService } = await import(
"../services/notificationService"
);
// Check if enrichment is enabled in system settings // Check if enrichment is enabled in system settings
const { prisma } = await import("../utils/db"); const { prisma } = await import("../utils/db");
@@ -158,7 +290,9 @@ router.post("/start", async (req, res) => {
}); });
if (!systemSettings?.autoEnrichMetadata) { if (!systemSettings?.autoEnrichMetadata) {
return res.status(400).json({ error: "Enrichment is not enabled. Enable it in settings first." }); return res.status(400).json({
error: "Enrichment is not enabled. Enable it in settings first.",
});
} }
// Get user enrichment settings or use defaults // Get user enrichment settings or use defaults
@@ -175,50 +309,282 @@ router.post("/start", async (req, res) => {
); );
// Start enrichment in background // Start enrichment in background
enrichmentService.enrichLibrary(userId).then(async () => { enrichmentService
// Send notification when complete .enrichLibrary(userId)
await notificationService.notifySystem( .then(async () => {
userId, // Send notification when complete
"Library Enrichment Complete", await notificationService.notifySystem(
"All artist metadata has been enriched" userId,
); "Library Enrichment Complete",
}).catch(async (error) => { "All artist metadata has been enriched"
console.error("Background enrichment failed:", error); );
await notificationService.create({ })
userId, .catch(async (error) => {
type: "error", logger.error("Background enrichment failed:", error);
title: "Enrichment Failed", await notificationService.create({
message: error.message || "Failed to enrich library metadata", userId,
type: "error",
title: "Enrichment Failed",
message:
error.message || "Failed to enrich library metadata",
});
}); });
});
res.json({ res.json({
success: true, success: true,
message: "Library enrichment started in background", message: "Library enrichment started in background",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Start enrichment error:", error); logger.error("Start enrichment error:", error);
res.status(500).json({ error: error.message || "Failed to start enrichment" }); res.status(500).json({
error: error.message || "Failed to start enrichment",
});
} }
}); });
/** /**
* PUT /library/artists/:id/metadata * GET /enrichment/failures
* Update artist metadata manually * Get all enrichment failures with filtering
*/
router.get("/failures", async (req, res) => {
try {
const { entityType, includeSkipped, includeResolved, limit, offset } =
req.query;
const options: any = {};
if (entityType) options.entityType = entityType as string;
if (includeSkipped === "true") options.includeSkipped = true;
if (includeResolved === "true") options.includeResolved = true;
if (limit) options.limit = parseInt(limit as string);
if (offset) options.offset = parseInt(offset as string);
const result = await enrichmentFailureService.getFailures(options);
res.json(result);
} catch (error) {
logger.error("Get failures error:", error);
res.status(500).json({ error: "Failed to get failures" });
}
});
/**
* GET /enrichment/failures/counts
* Get failure counts by type
*/
router.get("/failures/counts", async (req, res) => {
try {
const counts = await enrichmentFailureService.getFailureCounts();
res.json(counts);
} catch (error) {
logger.error("Get failure counts error:", error);
res.status(500).json({ error: "Failed to get failure counts" });
}
});
/**
* POST /enrichment/retry
* Retry specific failed items
*/
router.post("/retry", requireAdmin, async (req, res) => {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res
.status(400)
.json({ error: "Must provide array of failure IDs" });
}
// Reset retry count for these failures
await enrichmentFailureService.resetRetryCount(ids);
// Get the failures to determine what to retry
const failures = await Promise.all(
ids.map((id) => enrichmentFailureService.getFailure(id))
);
// Group by type and trigger appropriate re-enrichment
const { prisma } = await import("../utils/db");
let queued = 0;
let skipped = 0;
for (const failure of failures) {
if (!failure) continue;
try {
if (failure.entityType === "artist") {
// Check if artist still exists
const artist = await prisma.artist.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
if (!artist) {
// Entity was deleted - mark failure as resolved
await enrichmentFailureService.resolveFailures([
failure.id,
]);
skipped++;
continue;
}
// Reset artist enrichment status
await prisma.artist.update({
where: { id: failure.entityId },
data: { enrichmentStatus: "pending" },
});
queued++;
} else if (failure.entityType === "track") {
// Check if track still exists
const track = await prisma.track.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
if (!track) {
// Entity was deleted - mark failure as resolved
await enrichmentFailureService.resolveFailures([
failure.id,
]);
skipped++;
continue;
}
// Reset track tag status
await prisma.track.update({
where: { id: failure.entityId },
data: { lastfmTags: [] },
});
queued++;
} else if (failure.entityType === "audio") {
// Check if track still exists
const track = await prisma.track.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
if (!track) {
// Entity was deleted - mark failure as resolved
await enrichmentFailureService.resolveFailures([
failure.id,
]);
skipped++;
continue;
}
// Reset audio analysis status
await prisma.track.update({
where: { id: failure.entityId },
data: {
analysisStatus: "pending",
analysisRetryCount: 0,
},
});
queued++;
}
} catch (error) {
logger.error(
`Failed to reset ${failure.entityType} ${failure.entityId}:`,
error
);
// Don't re-throw - continue processing other failures
}
}
res.json({
message: `Queued ${queued} items for retry, ${skipped} skipped (entities no longer exist)`,
queued,
skipped,
});
} catch (error: any) {
logger.error("Retry failures error:", error);
res.status(500).json({
error: error.message || "Failed to retry failures",
});
}
});
/**
* POST /enrichment/skip
* Skip specific failures (won't retry automatically)
*/
router.post("/skip", requireAdmin, async (req, res) => {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res
.status(400)
.json({ error: "Must provide array of failure IDs" });
}
const count = await enrichmentFailureService.skipFailures(ids);
res.json({
message: `Skipped ${count} failures`,
count,
});
} catch (error: any) {
logger.error("Skip failures error:", error);
res.status(500).json({
error: error.message || "Failed to skip failures",
});
}
});
/**
* DELETE /enrichment/failures/:id
* Delete a specific failure record
*/
router.delete("/failures/:id", requireAdmin, async (req, res) => {
try {
const count = await enrichmentFailureService.deleteFailures([
req.params.id,
]);
res.json({
message: "Failure deleted",
count,
});
} catch (error: any) {
logger.error("Delete failure error:", error);
res.status(500).json({
error: error.message || "Failed to delete failure",
});
}
});
/**
* PUT /enrichment/artists/:id/metadata
* Update artist metadata manually (non-destructive overrides)
* User edits are stored as overrides; canonical data preserved for API lookups
*/ */
router.put("/artists/:id/metadata", async (req, res) => { router.put("/artists/:id/metadata", async (req, res) => {
try { try {
const { name, bio, genres, mbid, heroUrl } = req.body; const { name, bio, genres, heroUrl } = req.body;
const updateData: any = {}; const updateData: any = {};
if (name) updateData.name = name; let hasOverrides = false;
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 // Map user edits to override fields (non-destructive)
updateData.manuallyEdited = true; if (name !== undefined) {
updateData.displayName = name;
hasOverrides = true;
}
if (bio !== undefined) {
updateData.userSummary = bio;
hasOverrides = true;
}
if (heroUrl !== undefined) {
updateData.userHeroUrl = heroUrl;
hasOverrides = true;
}
if (genres !== undefined) {
updateData.userGenres = genres;
hasOverrides = true;
}
// Set override flag
if (hasOverrides) {
updateData.hasUserOverrides = true;
}
const { prisma } = await import("../utils/db"); const { prisma } = await import("../utils/db");
const artist = await prisma.artist.update({ const artist = await prisma.artist.update({
@@ -236,30 +602,56 @@ router.put("/artists/:id/metadata", async (req, res) => {
}, },
}); });
// Invalidate Redis cache for artist hero image
try {
await redisClient.del(`hero:${req.params.id}`);
} catch (err) {
logger.warn("Failed to invalidate Redis cache:", err);
}
res.json(artist); res.json(artist);
} catch (error: any) { } catch (error: any) {
console.error("Update artist metadata error:", error); logger.error("Update artist metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update artist" }); res.status(500).json({
error: error.message || "Failed to update artist",
});
} }
}); });
/** /**
* PUT /library/albums/:id/metadata * PUT /enrichment/albums/:id/metadata
* Update album metadata manually * Update album metadata manually (non-destructive overrides)
* User edits are stored as overrides; canonical data preserved for API lookups
*/ */
router.put("/albums/:id/metadata", async (req, res) => { router.put("/albums/:id/metadata", async (req, res) => {
try { try {
const { title, year, genres, rgMbid, coverUrl } = req.body; const { title, year, genres, coverUrl } = req.body;
const updateData: any = {}; const updateData: any = {};
if (title) updateData.title = title; let hasOverrides = false;
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 // Map user edits to override fields (non-destructive)
updateData.manuallyEdited = true; if (title !== undefined) {
updateData.displayTitle = title;
hasOverrides = true;
}
if (year !== undefined) {
updateData.displayYear = parseInt(year);
hasOverrides = true;
}
if (coverUrl !== undefined) {
updateData.userCoverUrl = coverUrl;
hasOverrides = true;
}
if (genres !== undefined) {
updateData.userGenres = genres;
hasOverrides = true;
}
// Set override flag
if (hasOverrides) {
updateData.hasUserOverrides = true;
}
const { prisma } = await import("../utils/db"); const { prisma } = await import("../utils/db");
const album = await prisma.album.update({ const album = await prisma.album.update({
@@ -285,8 +677,348 @@ router.put("/albums/:id/metadata", async (req, res) => {
res.json(album); res.json(album);
} catch (error: any) { } catch (error: any) {
console.error("Update album metadata error:", error); logger.error("Update album metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update album" }); res.status(500).json({
error: error.message || "Failed to update album",
});
}
});
/**
* PUT /enrichment/tracks/:id/metadata
* Update track metadata manually (non-destructive overrides)
* User edits are stored as overrides; canonical data preserved
*/
router.put("/tracks/:id/metadata", async (req, res) => {
try {
const { title, trackNo } = req.body;
const updateData: any = {};
let hasOverrides = false;
// Map user edits to override fields (non-destructive)
if (title !== undefined) {
updateData.displayTitle = title;
hasOverrides = true;
}
if (trackNo !== undefined) {
updateData.displayTrackNo = parseInt(trackNo);
hasOverrides = true;
}
// Set override flag
if (hasOverrides) {
updateData.hasUserOverrides = true;
}
const { prisma } = await import("../utils/db");
const track = await prisma.track.update({
where: { id: req.params.id },
data: updateData,
include: {
album: {
select: {
id: true,
title: true,
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
});
res.json(track);
} catch (error: any) {
logger.error("Update track metadata error:", error);
res.status(500).json({
error: error.message || "Failed to update track",
});
}
});
/**
* POST /enrichment/artists/:id/reset
* Reset artist metadata to canonical values (clear all user overrides)
*/
router.post("/artists/:id/reset", async (req, res) => {
try {
const { prisma } = await import("../utils/db");
// Check if artist exists first
const existingArtist = await prisma.artist.findUnique({
where: { id: req.params.id },
select: { id: true },
});
if (!existingArtist) {
return res.status(404).json({
error: "Artist not found",
message: "The artist may have been deleted",
});
}
const artist = await prisma.artist.update({
where: { id: req.params.id },
data: {
displayName: null,
userSummary: null,
userHeroUrl: null,
userGenres: [],
hasUserOverrides: false,
},
include: {
albums: {
select: {
id: true,
title: true,
year: true,
coverUrl: true,
},
},
},
});
// Invalidate Redis cache for artist hero image
try {
await redisClient.del(`hero:${req.params.id}`);
} catch (err) {
logger.warn("Failed to invalidate Redis cache:", err);
}
res.json({
message: "Artist metadata reset to original values",
artist,
});
} catch (error: any) {
// Handle P2025 specifically in case of race condition
if (error.code === "P2025") {
return res.status(404).json({
error: "Artist not found",
message: "The artist may have been deleted",
});
}
logger.error("Reset artist metadata error:", error);
res.status(500).json({
error: error.message || "Failed to reset artist metadata",
});
}
});
/**
* POST /enrichment/albums/:id/reset
* Reset album metadata to canonical values (clear all user overrides)
*/
router.post("/albums/:id/reset", async (req, res) => {
try {
const { prisma } = await import("../utils/db");
// Check if album exists first
const existingAlbum = await prisma.album.findUnique({
where: { id: req.params.id },
select: { id: true },
});
if (!existingAlbum) {
return res.status(404).json({
error: "Album not found",
message: "The album may have been deleted",
});
}
const album = await prisma.album.update({
where: { id: req.params.id },
data: {
displayTitle: null,
displayYear: null,
userCoverUrl: null,
userGenres: [],
hasUserOverrides: false,
},
include: {
artist: {
select: {
id: true,
name: true,
},
},
tracks: {
select: {
id: true,
title: true,
trackNo: true,
duration: true,
},
},
},
});
res.json({
message: "Album metadata reset to original values",
album,
});
} catch (error: any) {
// Handle P2025 specifically in case of race condition
if (error.code === "P2025") {
return res.status(404).json({
error: "Album not found",
message: "The album may have been deleted",
});
}
logger.error("Reset album metadata error:", error);
res.status(500).json({
error: error.message || "Failed to reset album metadata",
});
}
});
/**
* POST /enrichment/tracks/:id/reset
* Reset track metadata to canonical values (clear all user overrides)
*/
router.post("/tracks/:id/reset", async (req, res) => {
try {
const { prisma } = await import("../utils/db");
// Check if track exists first
const existingTrack = await prisma.track.findUnique({
where: { id: req.params.id },
select: { id: true },
});
if (!existingTrack) {
return res.status(404).json({
error: "Track not found",
message: "The track may have been deleted",
});
}
const track = await prisma.track.update({
where: { id: req.params.id },
data: {
displayTitle: null,
displayTrackNo: null,
hasUserOverrides: false,
},
include: {
album: {
select: {
id: true,
title: true,
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
});
res.json({
message: "Track metadata reset to original values",
track,
});
} catch (error: any) {
// Handle P2025 specifically in case of race condition
if (error.code === "P2025") {
return res.status(404).json({
error: "Track not found",
message: "The track may have been deleted",
});
}
logger.error("Reset track metadata error:", error);
res.status(500).json({
error: error.message || "Failed to reset track metadata",
});
}
});
/**
* GET /enrichment/concurrency
* Get current enrichment concurrency configuration
*/
router.get("/concurrency", async (req, res) => {
try {
const settings = await getSystemSettings();
const concurrency = settings?.enrichmentConcurrency || 1;
// Calculate estimated speeds based on concurrency
const artistsPerMin = Math.round(10 * concurrency);
const tracksPerMin = Math.round(60 * concurrency);
res.json({
concurrency,
estimatedSpeed: `~${artistsPerMin} artists/min, ~${tracksPerMin} tracks/min`,
artistsPerMin,
tracksPerMin,
});
} catch (error) {
logger.error("Failed to get enrichment settings:", error);
res.status(500).json({ error: "Failed to get enrichment settings" });
}
});
/**
* PUT /enrichment/concurrency
* Update enrichment concurrency configuration
*/
router.put("/concurrency", requireAdmin, async (req, res) => {
try {
const { concurrency } = req.body;
if (!concurrency || typeof concurrency !== "number") {
return res
.status(400)
.json({ error: "Missing or invalid 'concurrency' parameter" });
}
// Clamp concurrency to 1-5
const clampedConcurrency = Math.max(
1,
Math.min(5, Math.floor(concurrency))
);
// Update system settings in database
const { prisma } = await import("../utils/db");
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
enrichmentConcurrency: clampedConcurrency,
},
update: {
enrichmentConcurrency: clampedConcurrency,
},
});
// Invalidate cache so next read gets fresh value
invalidateSystemSettingsCache();
// Update rate limiter concurrency multiplier
rateLimiter.updateConcurrencyMultiplier(clampedConcurrency);
// Calculate estimated speeds
const artistsPerMin = Math.round(10 * clampedConcurrency);
const tracksPerMin = Math.round(60 * clampedConcurrency);
logger.debug(
`[Enrichment Settings] Updated concurrency to ${clampedConcurrency}`
);
res.json({
concurrency: clampedConcurrency,
estimatedSpeed: `~${artistsPerMin} artists/min, ~${tracksPerMin} tracks/min`,
artistsPerMin,
tracksPerMin,
});
} catch (error) {
logger.error("Failed to update enrichment settings:", error);
res.status(500).json({ error: "Failed to update enrichment settings" });
} }
}); });
+25 -18
View File
@@ -1,6 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma, Prisma } from "../utils/db";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
const router = Router(); const router = Router();
@@ -22,14 +23,14 @@ router.get("/genres", async (req, res) => {
try { try {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log(`[HOMEPAGE] Cache HIT for genres`); logger.debug(`[HOMEPAGE] Cache HIT for genres`);
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
} catch (cacheError) { } catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError); logger.warn("[HOMEPAGE] Redis cache read error:", cacheError);
} }
console.log( logger.debug(
`[HOMEPAGE] ✗ Cache MISS for genres, fetching from database...` `[HOMEPAGE] ✗ Cache MISS for genres, fetching from database...`
); );
@@ -37,7 +38,7 @@ router.get("/genres", async (req, res) => {
const albums = await prisma.album.findMany({ const albums = await prisma.album.findMany({
where: { where: {
genres: { genres: {
isEmpty: false, // Only albums with genres not: Prisma.JsonNull, // Only albums with genres (not null)
}, },
location: "LIBRARY", // Exclude discovery albums location: "LIBRARY", // Exclude discovery albums
}, },
@@ -60,8 +61,11 @@ router.get("/genres", async (req, res) => {
// Count genre occurrences // Count genre occurrences
const genreCounts = new Map<string, number>(); const genreCounts = new Map<string, number>();
for (const album of albums) { for (const album of albums) {
for (const genre of album.genres) { const genres = album.genres as string[];
genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1); if (genres && Array.isArray(genres)) {
for (const genre of genres) {
genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1);
}
} }
} }
@@ -71,12 +75,15 @@ router.get("/genres", async (req, res) => {
.slice(0, limitNum) .slice(0, limitNum)
.map(([genre]) => genre); .map(([genre]) => genre);
console.log(`[HOMEPAGE] Top genres: ${topGenres.join(", ")}`); logger.debug(`[HOMEPAGE] Top genres: ${topGenres.join(", ")}`);
// For each top genre, get sample albums (up to 10) // For each top genre, get sample albums (up to 10)
const genresWithAlbums = topGenres.map((genre) => { const genresWithAlbums = topGenres.map((genre) => {
const genreAlbums = albums const genreAlbums = albums
.filter((a) => a.genres.includes(genre)) .filter((a) => {
const genres = a.genres as string[];
return genres && Array.isArray(genres) && genres.includes(genre);
})
.slice(0, 10) .slice(0, 10)
.map((a) => ({ .map((a) => ({
id: a.id, id: a.id,
@@ -103,14 +110,14 @@ router.get("/genres", async (req, res) => {
24 * 60 * 60, 24 * 60 * 60,
JSON.stringify(genresWithAlbums) JSON.stringify(genresWithAlbums)
); );
console.log(`[HOMEPAGE] Cached genres for 24 hours`); logger.debug(`[HOMEPAGE] Cached genres for 24 hours`);
} catch (cacheError) { } catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError); logger.warn("[HOMEPAGE] Redis cache write error:", cacheError);
} }
res.json(genresWithAlbums); res.json(genresWithAlbums);
} catch (error) { } catch (error) {
console.error("Get homepage genres error:", error); logger.error("Get homepage genres error:", error);
res.status(500).json({ error: "Failed to fetch genres" }); res.status(500).json({ error: "Failed to fetch genres" });
} }
}); });
@@ -129,14 +136,14 @@ router.get("/top-podcasts", async (req, res) => {
try { try {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log(`[HOMEPAGE] Cache HIT for top podcasts`); logger.debug(`[HOMEPAGE] Cache HIT for top podcasts`);
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
} catch (cacheError) { } catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError); logger.warn("[HOMEPAGE] Redis cache read error:", cacheError);
} }
console.log( logger.debug(
`[HOMEPAGE] ✗ Cache MISS for top podcasts, fetching from database...` `[HOMEPAGE] ✗ Cache MISS for top podcasts, fetching from database...`
); );
@@ -172,14 +179,14 @@ router.get("/top-podcasts", async (req, res) => {
24 * 60 * 60, 24 * 60 * 60,
JSON.stringify(result) JSON.stringify(result)
); );
console.log(`[HOMEPAGE] Cached top podcasts for 24 hours`); logger.debug(`[HOMEPAGE] Cached top podcasts for 24 hours`);
} catch (cacheError) { } catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError); logger.warn("[HOMEPAGE] Redis cache write error:", cacheError);
} }
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error("Get top podcasts error:", error); logger.error("Get top podcasts error:", error);
res.status(500).json({ error: "Failed to fetch top podcasts" }); res.status(500).json({ error: "Failed to fetch top podcasts" });
} }
}); });
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod"; import { z } from "zod";
@@ -46,7 +47,7 @@ router.post("/", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: error.errors }); .json({ error: "Invalid request", details: error.errors });
} }
console.error("Update listening state error:", error); logger.error("Update listening state error:", error);
res.status(500).json({ error: "Failed to update listening state" }); res.status(500).json({ error: "Failed to update listening state" });
} }
}); });
@@ -79,7 +80,7 @@ router.get("/", async (req, res) => {
res.json(state); res.json(state);
} catch (error) { } catch (error) {
console.error("Get listening state error:", error); logger.error("Get listening state error:", error);
res.status(500).json({ error: "Failed to get listening state" }); res.status(500).json({ error: "Failed to get listening state" });
} }
}); });
@@ -98,7 +99,7 @@ router.get("/recent", async (req, res) => {
res.json(states); res.json(states);
} catch (error) { } catch (error) {
console.error("Get recent listening states error:", error); logger.error("Get recent listening states error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to get recent listening states", error: "Failed to get recent listening states",
}); });
+18 -18
View File
@@ -1,5 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth"; import { logger } from "../utils/logger";
import { requireAuthOrToken, requireAdmin } from "../middleware/auth";
import { programmaticPlaylistService } from "../services/programmaticPlaylists"; import { programmaticPlaylistService } from "../services/programmaticPlaylists";
import { import {
moodBucketService, moodBucketService,
@@ -93,7 +94,7 @@ router.get("/", async (req, res) => {
res.json(mixes); res.json(mixes);
} catch (error) { } catch (error) {
console.error("Get mixes error:", error); logger.error("Get mixes error:", error);
res.status(500).json({ error: "Failed to get mixes" }); res.status(500).json({ error: "Failed to get mixes" });
} }
}); });
@@ -252,7 +253,7 @@ router.post("/mood", async (req, res) => {
.map((id: string) => tracks.find((t) => t.id === id)) .map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined); .filter((t: any) => t !== undefined);
console.log( logger.debug(
`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks` `[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`
); );
@@ -261,7 +262,7 @@ router.post("/mood", async (req, res) => {
tracks: orderedTracks, tracks: orderedTracks,
}); });
} catch (error) { } catch (error) {
console.error("Generate mood mix error:", error); logger.error("Generate mood mix error:", error);
res.status(500).json({ error: "Failed to generate mood mix" }); res.status(500).json({ error: "Failed to generate mood mix" });
} }
}); });
@@ -430,11 +431,11 @@ router.post("/mood/save-preferences", async (req, res) => {
const cacheKey = `mixes:${userId}`; const cacheKey = `mixes:${userId}`;
await redisClient.del(cacheKey); await redisClient.del(cacheKey);
console.log(`[MIXES] Saved mood mix preferences for user ${userId}`); logger.debug(`[MIXES] Saved mood mix preferences for user ${userId}`);
res.json({ success: true, message: "Mood preferences saved" }); res.json({ success: true, message: "Mood preferences saved" });
} catch (error) { } catch (error) {
console.error("Save mood preferences error:", error); logger.error("Save mood preferences error:", error);
res.status(500).json({ error: "Failed to save mood preferences" }); res.status(500).json({ error: "Failed to save mood preferences" });
} }
}); });
@@ -462,7 +463,7 @@ router.get("/mood/buckets/presets", async (req, res) => {
const presets = await moodBucketService.getMoodPresets(); const presets = await moodBucketService.getMoodPresets();
res.json(presets); res.json(presets);
} catch (error) { } catch (error) {
console.error("Get mood presets error:", error); logger.error("Get mood presets error:", error);
res.status(500).json({ error: "Failed to get mood presets" }); res.status(500).json({ error: "Failed to get mood presets" });
} }
}); });
@@ -535,7 +536,7 @@ router.get("/mood/buckets/:mood", async (req, res) => {
tracks: orderedTracks, tracks: orderedTracks,
}); });
} catch (error) { } catch (error) {
console.error("Get mood bucket mix error:", error); logger.error("Get mood bucket mix error:", error);
res.status(500).json({ error: "Failed to get mood mix" }); res.status(500).json({ error: "Failed to get mood mix" });
} }
}); });
@@ -611,7 +612,7 @@ router.post("/mood/buckets/:mood/save", async (req, res) => {
.map((id: string) => tracks.find((t) => t.id === id)) .map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined); .filter((t: any) => t !== undefined);
console.log( logger.debug(
`[MIXES] Saved mood bucket mix for user ${userId}: ${mood} (${savedMix.trackCount} tracks)` `[MIXES] Saved mood bucket mix for user ${userId}: ${mood} (${savedMix.trackCount} tracks)`
); );
@@ -623,7 +624,7 @@ router.post("/mood/buckets/:mood/save", async (req, res) => {
}, },
}); });
} catch (error) { } catch (error) {
console.error("Save mood bucket mix error:", error); logger.error("Save mood bucket mix error:", error);
res.status(500).json({ error: "Failed to save mood mix" }); res.status(500).json({ error: "Failed to save mood mix" });
} }
}); });
@@ -642,15 +643,14 @@ router.post("/mood/buckets/:mood/save", async (req, res) => {
* 200: * 200:
* description: Backfill completed * description: Backfill completed
*/ */
router.post("/mood/buckets/backfill", async (req, res) => { router.post("/mood/buckets/backfill", requireAdmin, async (req, res) => {
try { try {
const userId = getRequestUserId(req); const userId = getRequestUserId(req);
if (!userId) { if (!userId) {
return res.status(401).json({ error: "Not authenticated" }); return res.status(401).json({ error: "Not authenticated" });
} }
// TODO: Add admin check logger.debug(
console.log(
`[MIXES] Starting mood bucket backfill requested by user ${userId}` `[MIXES] Starting mood bucket backfill requested by user ${userId}`
); );
@@ -662,7 +662,7 @@ router.post("/mood/buckets/backfill", async (req, res) => {
assigned: result.assigned, assigned: result.assigned,
}); });
} catch (error) { } catch (error) {
console.error("Backfill mood buckets error:", error); logger.error("Backfill mood buckets error:", error);
res.status(500).json({ error: "Failed to backfill mood buckets" }); res.status(500).json({ error: "Failed to backfill mood buckets" });
} }
}); });
@@ -721,7 +721,7 @@ router.post("/refresh", async (req, res) => {
res.json({ message: "Mixes refreshed", mixes }); res.json({ message: "Mixes refreshed", mixes });
} catch (error) { } catch (error) {
console.error("Refresh mixes error:", error); logger.error("Refresh mixes error:", error);
res.status(500).json({ error: "Failed to refresh mixes" }); res.status(500).json({ error: "Failed to refresh mixes" });
} }
}); });
@@ -849,7 +849,7 @@ router.post("/:id/save", async (req, res) => {
data: playlistItems, data: playlistItems,
}); });
console.log( logger.debug(
`[MIXES] Saved mix ${mixId} as playlist ${playlist.id} (${mix.trackIds.length} tracks)` `[MIXES] Saved mix ${mixId} as playlist ${playlist.id} (${mix.trackIds.length} tracks)`
); );
@@ -859,7 +859,7 @@ router.post("/:id/save", async (req, res) => {
trackCount: mix.trackIds.length, trackCount: mix.trackIds.length,
}); });
} catch (error) { } catch (error) {
console.error("Save mix as playlist error:", error); logger.error("Save mix as playlist error:", error);
res.status(500).json({ error: "Failed to save mix as playlist" }); res.status(500).json({ error: "Failed to save mix as playlist" });
} }
}); });
@@ -982,7 +982,7 @@ router.get("/:id", async (req, res) => {
tracks: orderedTracks, tracks: orderedTracks,
}); });
} catch (error) { } catch (error) {
console.error("Get mix error:", error); logger.error("Get mix error:", error);
res.status(500).json({ error: "Failed to get mix" }); res.status(500).json({ error: "Failed to get mix" });
} }
}); });
+51 -39
View File
@@ -1,6 +1,7 @@
import { Router, Response } from "express"; import { Router, Request, Response } from "express";
import { logger } from "../utils/logger";
import { notificationService } from "../services/notificationService"; import { notificationService } from "../services/notificationService";
import { AuthenticatedRequest, requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
const router = Router(); const router = Router();
@@ -12,9 +13,9 @@ const router = Router();
router.get( router.get(
"/", "/",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
console.log( logger.debug(
`[Notifications] Fetching notifications for user ${ `[Notifications] Fetching notifications for user ${
req.user!.id req.user!.id
}` }`
@@ -22,12 +23,12 @@ router.get(
const notifications = await notificationService.getForUser( const notifications = await notificationService.getForUser(
req.user!.id req.user!.id
); );
console.log( logger.debug(
`[Notifications] Found ${notifications.length} notifications` `[Notifications] Found ${notifications.length} notifications`
); );
res.json(notifications); res.json(notifications);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching notifications:", error); logger.error("Error fetching notifications:", error);
res.status(500).json({ error: "Failed to fetch notifications" }); res.status(500).json({ error: "Failed to fetch notifications" });
} }
} }
@@ -40,14 +41,14 @@ router.get(
router.get( router.get(
"/unread-count", "/unread-count",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
const count = await notificationService.getUnreadCount( const count = await notificationService.getUnreadCount(
req.user!.id req.user!.id
); );
res.json({ count }); res.json({ count });
} catch (error: any) { } catch (error: any) {
console.error("Error fetching unread count:", error); logger.error("Error fetching unread count:", error);
res.status(500).json({ error: "Failed to fetch unread count" }); res.status(500).json({ error: "Failed to fetch unread count" });
} }
} }
@@ -60,12 +61,12 @@ router.get(
router.post( router.post(
"/:id/read", "/:id/read",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
await notificationService.markAsRead(req.params.id, req.user!.id); await notificationService.markAsRead(req.params.id, req.user!.id);
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Error marking notification as read:", error); logger.error("Error marking notification as read:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to mark notification as read", error: "Failed to mark notification as read",
}); });
@@ -80,12 +81,12 @@ router.post(
router.post( router.post(
"/read-all", "/read-all",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
await notificationService.markAllAsRead(req.user!.id); await notificationService.markAllAsRead(req.user!.id);
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Error marking all notifications as read:", error); logger.error("Error marking all notifications as read:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to mark all notifications as read", error: "Failed to mark all notifications as read",
}); });
@@ -100,12 +101,12 @@ router.post(
router.post( router.post(
"/:id/clear", "/:id/clear",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
await notificationService.clear(req.params.id, req.user!.id); await notificationService.clear(req.params.id, req.user!.id);
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Error clearing notification:", error); logger.error("Error clearing notification:", error);
res.status(500).json({ error: "Failed to clear notification" }); res.status(500).json({ error: "Failed to clear notification" });
} }
} }
@@ -118,12 +119,12 @@ router.post(
router.post( router.post(
"/clear-all", "/clear-all",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
await notificationService.clearAll(req.user!.id); await notificationService.clearAll(req.user!.id);
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Error clearing all notifications:", error); logger.error("Error clearing all notifications:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to clear all notifications", error: "Failed to clear all notifications",
}); });
@@ -138,11 +139,12 @@ router.post(
/** /**
* GET /notifications/downloads/history * GET /notifications/downloads/history
* Get completed/failed downloads that haven't been cleared * Get completed/failed downloads that haven't been cleared
* Deduplicated by album subject (shows only most recent entry per album)
*/ */
router.get( router.get(
"/downloads/history", "/downloads/history",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
const downloads = await prisma.downloadJob.findMany({ const downloads = await prisma.downloadJob.findMany({
where: { where: {
@@ -151,11 +153,23 @@ router.get(
cleared: false, cleared: false,
}, },
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
take: 50, take: 100, // Fetch more to account for duplicates
}); });
res.json(downloads);
// Deduplicate by subject - keep only the most recent entry per album
const seen = new Set<string>();
const deduplicated = downloads.filter((download) => {
if (seen.has(download.subject)) {
return false; // Skip duplicate
}
seen.add(download.subject);
return true; // Keep first occurrence (most recent due to ordering)
});
// Return top 50 after deduplication
res.json(deduplicated.slice(0, 50));
} catch (error: any) { } catch (error: any) {
console.error("Error fetching download history:", error); logger.error("Error fetching download history:", error);
res.status(500).json({ error: "Failed to fetch download history" }); res.status(500).json({ error: "Failed to fetch download history" });
} }
} }
@@ -168,7 +182,7 @@ router.get(
router.get( router.get(
"/downloads/active", "/downloads/active",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
const downloads = await prisma.downloadJob.findMany({ const downloads = await prisma.downloadJob.findMany({
where: { where: {
@@ -179,7 +193,7 @@ router.get(
}); });
res.json(downloads); res.json(downloads);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching active downloads:", error); logger.error("Error fetching active downloads:", error);
res.status(500).json({ error: "Failed to fetch active downloads" }); res.status(500).json({ error: "Failed to fetch active downloads" });
} }
} }
@@ -192,7 +206,7 @@ router.get(
router.post( router.post(
"/downloads/:id/clear", "/downloads/:id/clear",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
await prisma.downloadJob.updateMany({ await prisma.downloadJob.updateMany({
where: { where: {
@@ -203,7 +217,7 @@ router.post(
}); });
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Error clearing download:", error); logger.error("Error clearing download:", error);
res.status(500).json({ error: "Failed to clear download" }); res.status(500).json({ error: "Failed to clear download" });
} }
} }
@@ -216,7 +230,7 @@ router.post(
router.post( router.post(
"/downloads/clear-all", "/downloads/clear-all",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
await prisma.downloadJob.updateMany({ await prisma.downloadJob.updateMany({
where: { where: {
@@ -228,7 +242,7 @@ router.post(
}); });
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Error clearing all downloads:", error); logger.error("Error clearing all downloads:", error);
res.status(500).json({ error: "Failed to clear all downloads" }); res.status(500).json({ error: "Failed to clear all downloads" });
} }
} }
@@ -241,7 +255,7 @@ router.post(
router.post( router.post(
"/downloads/:id/retry", "/downloads/:id/retry",
requireAuth, requireAuth,
async (req: AuthenticatedRequest, res: Response) => { async (req: Request, res: Response) => {
try { try {
// Get the failed download // Get the failed download
const failedJob = await prisma.downloadJob.findFirst({ const failedJob = await prisma.downloadJob.findFirst({
@@ -478,11 +492,9 @@ router.post(
const albumTitle = metadata.albumTitle as string; const albumTitle = metadata.albumTitle as string;
if (!artistName || !albumTitle) { if (!artistName || !albumTitle) {
return res return res.status(400).json({
.status(400) error: "Cannot retry: missing artist/album info",
.json({ });
error: "Cannot retry: missing artist/album info",
});
} }
// Mark old job as cleared // Mark old job as cleared
@@ -546,13 +558,13 @@ router.post(
}, },
]; ];
console.log( logger.debug(
`[Retry] Trying Soulseek for ${artistName} - ${albumTitle}` `[Retry] Trying Soulseek for ${artistName} - ${albumTitle}`
); );
// Run Soulseek search async // Run Soulseek search async
soulseekService soulseekService
.searchAndDownloadBatch(tracks, musicPath, 4) .searchAndDownloadBatch(tracks, musicPath, settings?.soulseekConcurrentDownloads || 4)
.then(async (result) => { .then(async (result) => {
if (result.successful > 0) { if (result.successful > 0) {
await prisma.downloadJob.update({ await prisma.downloadJob.update({
@@ -569,7 +581,7 @@ router.post(
}, },
}, },
}); });
console.log( logger.debug(
`[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}` `[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}`
); );
@@ -585,7 +597,7 @@ router.post(
}); });
} else { } else {
// Soulseek failed, try Lidarr if we have an MBID // Soulseek failed, try Lidarr if we have an MBID
console.log( logger.debug(
`[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}` `[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}`
); );
@@ -631,7 +643,7 @@ router.post(
} }
}) })
.catch(async (error) => { .catch(async (error) => {
console.error(`[Retry] Soulseek error:`, error); logger.error(`[Retry] Soulseek error:`, error);
await prisma.downloadJob.update({ await prisma.downloadJob.update({
where: { id: newJobRecord.id }, where: { id: newJobRecord.id },
data: { data: {
@@ -676,7 +688,7 @@ router.post(
artistMbid: failedJob.artistMbid, artistMbid: failedJob.artistMbid,
subject: failedJob.subject, subject: failedJob.subject,
status: "pending", status: "pending",
metadata: metadata || {}, metadata: (metadata || {}) as any,
}, },
}); });
@@ -702,7 +714,7 @@ router.post(
error: result.error, error: result.error,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error retrying download:", error); logger.error("Error retrying download:", error);
res.status(500).json({ error: "Failed to retry download" }); res.status(500).json({ error: "Failed to retry download" });
} }
} }
+9 -8
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod"; import { z } from "zod";
@@ -19,12 +20,12 @@ router.post("/albums/:id/download", async (req, res) => {
const { quality } = downloadAlbumSchema.parse(req.body); const { quality } = downloadAlbumSchema.parse(req.body);
// Get user's default quality if not specified // Get user's default quality if not specified
let selectedQuality = quality; let selectedQuality: "original" | "high" | "medium" | "low" = quality || "medium";
if (!selectedQuality) { if (!quality) {
const settings = await prisma.userSettings.findUnique({ const settings = await prisma.userSettings.findUnique({
where: { userId }, where: { userId },
}); });
selectedQuality = (settings?.playbackQuality as any) || "medium"; selectedQuality = (settings?.playbackQuality as "original" | "high" | "medium" | "low") || "medium";
} }
// Get album with tracks // Get album with tracks
@@ -103,7 +104,7 @@ router.post("/albums/:id/download", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: error.errors }); .json({ error: "Invalid request", details: error.errors });
} }
console.error("Create download job error:", error); logger.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" }); res.status(500).json({ error: "Failed to create download job" });
} }
}); });
@@ -145,7 +146,7 @@ router.post("/tracks/:id/complete", async (req, res) => {
res.json(cachedTrack); res.json(cachedTrack);
} catch (error) { } catch (error) {
console.error("Complete track download error:", error); logger.error("Complete track download error:", error);
res.status(500).json({ error: "Failed to complete download" }); res.status(500).json({ error: "Failed to complete download" });
} }
}); });
@@ -209,7 +210,7 @@ router.get("/albums", async (req, res) => {
res.json(albums); res.json(albums);
} catch (error) { } catch (error) {
console.error("Get cached albums error:", error); logger.error("Get cached albums error:", error);
res.status(500).json({ error: "Failed to get cached albums" }); res.status(500).json({ error: "Failed to get cached albums" });
} }
}); });
@@ -245,7 +246,7 @@ router.delete("/albums/:id", async (req, res) => {
deletedCount: cachedTracks.length, deletedCount: cachedTracks.length,
}); });
} catch (error) { } catch (error) {
console.error("Delete cached album error:", error); logger.error("Delete cached album error:", error);
res.status(500).json({ error: "Failed to delete cached album" }); res.status(500).json({ error: "Failed to delete cached album" });
} }
}); });
@@ -278,7 +279,7 @@ router.get("/stats", async (req, res) => {
trackCount, trackCount,
}); });
} catch (error) { } catch (error) {
console.error("Get cache stats error:", error); logger.error("Get cache stats error:", error);
res.status(500).json({ error: "Failed to get cache stats" }); res.status(500).json({ error: "Failed to get cache stats" });
} }
}); });
+22 -20
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import { z } from "zod"; import { z } from "zod";
@@ -49,14 +50,14 @@ async function ensureEncryptionKey(): Promise<void> {
process.env.SETTINGS_ENCRYPTION_KEY !== process.env.SETTINGS_ENCRYPTION_KEY !==
"default-encryption-key-change-me" "default-encryption-key-change-me"
) { ) {
console.log("[ONBOARDING] Encryption key already exists"); logger.debug("[ONBOARDING] Encryption key already exists");
return; return;
} }
// Generate a secure 32-byte encryption key // Generate a secure 32-byte encryption key
const encryptionKey = crypto.randomBytes(32).toString("base64"); const encryptionKey = crypto.randomBytes(32).toString("base64");
console.log( logger.debug(
"[ONBOARDING] Generating encryption key for settings security..." "[ONBOARDING] Generating encryption key for settings security..."
); );
@@ -69,9 +70,9 @@ async function ensureEncryptionKey(): Promise<void> {
// Update the process environment so it's available immediately // Update the process environment so it's available immediately
process.env.SETTINGS_ENCRYPTION_KEY = encryptionKey; process.env.SETTINGS_ENCRYPTION_KEY = encryptionKey;
console.log("[ONBOARDING] Encryption key generated and saved to .env"); logger.debug("[ONBOARDING] Encryption key generated and saved to .env");
} catch (error) { } catch (error) {
console.error("[ONBOARDING] Failed to save encryption key:", error); logger.error("[ONBOARDING] Failed to save encryption key:", error);
throw new Error("Failed to generate encryption key"); throw new Error("Failed to generate encryption key");
} }
} }
@@ -82,7 +83,7 @@ async function ensureEncryptionKey(): Promise<void> {
*/ */
router.post("/register", async (req, res) => { router.post("/register", async (req, res) => {
try { try {
console.log("[ONBOARDING] Register attempt for user:", req.body?.username); logger.debug("[ONBOARDING] Register attempt for user:", req.body?.username);
const { username, password } = registerSchema.parse(req.body); const { username, password } = registerSchema.parse(req.body);
// Check if any user exists (first user becomes admin) // Check if any user exists (first user becomes admin)
@@ -100,7 +101,7 @@ router.post("/register", async (req, res) => {
}); });
if (existing) { if (existing) {
console.log("[ONBOARDING] Username already taken:", username); logger.debug("[ONBOARDING] Username already taken:", username);
return res.status(400).json({ error: "Username already taken" }); return res.status(400).json({ error: "Username already taken" });
} }
@@ -131,9 +132,10 @@ router.post("/register", async (req, res) => {
id: user.id, id: user.id,
username: user.username, username: user.username,
role: user.role, role: user.role,
tokenVersion: user.tokenVersion,
}); });
console.log("[ONBOARDING] User created successfully:", user.username); logger.debug("[ONBOARDING] User created successfully:", user.username);
res.json({ res.json({
token, token,
user: { user: {
@@ -145,12 +147,12 @@ router.post("/register", async (req, res) => {
}); });
} catch (err: any) { } catch (err: any) {
if (err instanceof z.ZodError) { if (err instanceof z.ZodError) {
console.error("[ONBOARDING] Validation error:", err.errors); logger.error("[ONBOARDING] Validation error:", err.errors);
return res return res
.status(400) .status(400)
.json({ error: "Invalid request", details: err.errors }); .json({ error: "Invalid request", details: err.errors });
} }
console.error("Registration error:", err); logger.error("Registration error:", err);
res.status(500).json({ error: "Failed to create account" }); res.status(500).json({ error: "Failed to create account" });
} }
}); });
@@ -189,10 +191,10 @@ router.post("/lidarr", requireAuth, async (req, res) => {
if (response.status === 200) { if (response.status === 200) {
connectionTested = true; connectionTested = true;
console.log("Lidarr connection test successful"); logger.debug("Lidarr connection test successful");
} }
} catch (error: any) { } catch (error: any) {
console.warn( logger.warn(
" Lidarr connection test failed (saved anyway):", " Lidarr connection test failed (saved anyway):",
error.message error.message
); );
@@ -229,7 +231,7 @@ router.post("/lidarr", requireAuth, async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: err.errors }); .json({ error: "Invalid request", details: err.errors });
} }
console.error("Lidarr config error:", err); logger.error("Lidarr config error:", err);
res.status(500).json({ error: "Failed to save configuration" }); res.status(500).json({ error: "Failed to save configuration" });
} }
}); });
@@ -265,10 +267,10 @@ router.post("/audiobookshelf", requireAuth, async (req, res) => {
if (response.status === 200) { if (response.status === 200) {
connectionTested = true; connectionTested = true;
console.log("Audiobookshelf connection test successful"); logger.debug("Audiobookshelf connection test successful");
} }
} catch (error: any) { } catch (error: any) {
console.warn( logger.warn(
" Audiobookshelf connection test failed (saved anyway):", " Audiobookshelf connection test failed (saved anyway):",
error.message error.message
); );
@@ -305,7 +307,7 @@ router.post("/audiobookshelf", requireAuth, async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: err.errors }); .json({ error: "Invalid request", details: err.errors });
} }
console.error("Audiobookshelf config error:", err); logger.error("Audiobookshelf config error:", err);
res.status(500).json({ error: "Failed to save configuration" }); res.status(500).json({ error: "Failed to save configuration" });
} }
}); });
@@ -363,7 +365,7 @@ router.post("/soulseek", requireAuth, async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: err.errors }); .json({ error: "Invalid request", details: err.errors });
} }
console.error("Soulseek config error:", err); logger.error("Soulseek config error:", err);
res.status(500).json({ error: "Failed to save configuration" }); res.status(500).json({ error: "Failed to save configuration" });
} }
}); });
@@ -394,7 +396,7 @@ router.post("/enrichment", requireAuth, async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: err.errors }); .json({ error: "Invalid request", details: err.errors });
} }
console.error("Enrichment config error:", err); logger.error("Enrichment config error:", err);
res.status(500).json({ error: "Failed to save configuration" }); res.status(500).json({ error: "Failed to save configuration" });
} }
}); });
@@ -410,10 +412,10 @@ router.post("/complete", requireAuth, async (req, res) => {
data: { onboardingComplete: true }, data: { onboardingComplete: true },
}); });
console.log("[ONBOARDING] User completed onboarding:", req.user!.id); logger.debug("[ONBOARDING] User completed onboarding:", req.user!.id);
res.json({ success: true }); res.json({ success: true });
} catch (err: any) { } catch (err: any) {
console.error("Onboarding complete error:", err); logger.error("Onboarding complete error:", err);
res.status(500).json({ error: "Failed to complete onboarding" }); res.status(500).json({ error: "Failed to complete onboarding" });
} }
}); });
@@ -467,7 +469,7 @@ router.get("/status", async (req, res) => {
}); });
} }
} catch (err: any) { } catch (err: any) {
console.error("Onboarding status error:", err); logger.error("Onboarding status error:", err);
res.status(500).json({ error: "Failed to check status" }); res.status(500).json({ error: "Failed to check status" });
} }
}); });
+12 -11
View File
@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import { prisma } from "../utils/db"; import { logger } from "../utils/logger";
import { prisma, Prisma } from "../utils/db";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
const router = express.Router(); const router = express.Router();
@@ -19,7 +20,7 @@ router.get("/", requireAuth, async (req, res) => {
res.json(playbackState); res.json(playbackState);
} catch (error) { } catch (error) {
console.error("Get playback state error:", error); logger.error("Get playback state error:", error);
res.status(500).json({ error: "Failed to get playback state" }); res.status(500).json({ error: "Failed to get playback state" });
} }
}); });
@@ -46,7 +47,7 @@ router.post("/", requireAuth, async (req, res) => {
// Validate playback type // Validate playback type
const validPlaybackTypes = ["track", "audiobook", "podcast"]; const validPlaybackTypes = ["track", "audiobook", "podcast"];
if (!validPlaybackTypes.includes(playbackType)) { if (!validPlaybackTypes.includes(playbackType)) {
console.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`); logger.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`);
return res.status(400).json({ error: "Invalid playbackType" }); return res.status(400).json({ error: "Invalid playbackType" });
} }
@@ -79,7 +80,7 @@ router.post("/", requireAuth, async (req, res) => {
safeQueue = null; safeQueue = null;
} }
} catch (sanitizeError: any) { } catch (sanitizeError: any) {
console.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message); logger.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message);
safeQueue = null; // Fall back to null queue safeQueue = null; // Fall back to null queue
} }
} }
@@ -96,7 +97,7 @@ router.post("/", requireAuth, async (req, res) => {
trackId: trackId || null, trackId: trackId || null,
audiobookId: audiobookId || null, audiobookId: audiobookId || null,
podcastId: podcastId || null, podcastId: podcastId || null,
queue: safeQueue, queue: safeQueue === null ? Prisma.DbNull : safeQueue,
currentIndex: safeCurrentIndex, currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false, isShuffle: isShuffle || false,
}, },
@@ -106,7 +107,7 @@ router.post("/", requireAuth, async (req, res) => {
trackId: trackId || null, trackId: trackId || null,
audiobookId: audiobookId || null, audiobookId: audiobookId || null,
podcastId: podcastId || null, podcastId: podcastId || null,
queue: safeQueue, queue: safeQueue === null ? Prisma.DbNull : safeQueue,
currentIndex: safeCurrentIndex, currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false, isShuffle: isShuffle || false,
}, },
@@ -114,13 +115,13 @@ router.post("/", requireAuth, async (req, res) => {
res.json(playbackState); res.json(playbackState);
} catch (error: any) { } catch (error: any) {
console.error("[PlaybackState] Error saving state:", error?.message || error); logger.error("[PlaybackState] Error saving state:", error?.message || error);
console.error("[PlaybackState] Full error:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2)); logger.error("[PlaybackState] Full error:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
if (error?.code) { if (error?.code) {
console.error("[PlaybackState] Error code:", error.code); logger.error("[PlaybackState] Error code:", error.code);
} }
if (error?.meta) { if (error?.meta) {
console.error("[PlaybackState] Prisma meta:", error.meta); logger.error("[PlaybackState] Prisma meta:", error.meta);
} }
// Return more specific error for debugging // Return more specific error for debugging
res.status(500).json({ res.status(500).json({
@@ -141,7 +142,7 @@ router.delete("/", requireAuth, async (req, res) => {
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Delete playback state error:", error); logger.error("Delete playback state error:", error);
res.status(500).json({ error: "Failed to delete playback state" }); res.status(500).json({ error: "Failed to delete playback state" });
} }
}); });
+60 -32
View File
@@ -1,7 +1,8 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { z } from "zod";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod";
import { sessionLog } from "../utils/playlistLogger"; import { sessionLog } from "../utils/playlistLogger";
const router = Router(); const router = Router();
@@ -20,6 +21,9 @@ const addTrackSchema = z.object({
// GET /playlists // GET /playlists
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
// Get user's hidden playlists // Get user's hidden playlists
@@ -74,11 +78,11 @@ router.get("/", async (req, res) => {
// Debug: log shared playlists with user info // Debug: log shared playlists with user info
const sharedPlaylists = playlistsWithCounts.filter((p) => !p.isOwner); const sharedPlaylists = playlistsWithCounts.filter((p) => !p.isOwner);
if (sharedPlaylists.length > 0) { if (sharedPlaylists.length > 0) {
console.log( logger.debug(
`[Playlists] Found ${sharedPlaylists.length} shared playlists for user ${userId}:` `[Playlists] Found ${sharedPlaylists.length} shared playlists for user ${userId}:`
); );
sharedPlaylists.forEach((p) => { sharedPlaylists.forEach((p) => {
console.log( logger.debug(
` - "${p.name}" by ${ ` - "${p.name}" by ${
p.user?.username || "UNKNOWN" p.user?.username || "UNKNOWN"
} (owner: ${p.userId})` } (owner: ${p.userId})`
@@ -88,7 +92,7 @@ router.get("/", async (req, res) => {
res.json(playlistsWithCounts); res.json(playlistsWithCounts);
} catch (error) { } catch (error) {
console.error("Get playlists error:", error); logger.error("Get playlists error:", error);
res.status(500).json({ error: "Failed to get playlists" }); res.status(500).json({ error: "Failed to get playlists" });
} }
}); });
@@ -96,6 +100,9 @@ router.get("/", async (req, res) => {
// POST /playlists // POST /playlists
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body); const data = createPlaylistSchema.parse(req.body);
@@ -114,7 +121,7 @@ router.post("/", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: error.errors }); .json({ error: "Invalid request", details: error.errors });
} }
console.error("Create playlist error:", error); logger.error("Create playlist error:", error);
res.status(500).json({ error: "Failed to create playlist" }); res.status(500).json({ error: "Failed to create playlist" });
} }
}); });
@@ -122,6 +129,9 @@ router.post("/", async (req, res) => {
// GET /playlists/:id // GET /playlists/:id
router.get("/:id", async (req, res) => { router.get("/:id", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
const playlist = await prisma.playlist.findUnique({ const playlist = await prisma.playlist.findUnique({
@@ -132,6 +142,10 @@ router.get("/:id", async (req, res) => {
username: true, username: true,
}, },
}, },
hiddenByUsers: {
where: { userId },
select: { id: true },
},
items: { items: {
include: { include: {
track: { track: {
@@ -203,6 +217,7 @@ router.get("/:id", async (req, res) => {
res.json({ res.json({
...playlist, ...playlist,
isOwner: playlist.userId === userId, isOwner: playlist.userId === userId,
isHidden: playlist.hiddenByUsers.length > 0,
trackCount: playlist.items.length, trackCount: playlist.items.length,
pendingCount: playlist.pendingTracks.length, pendingCount: playlist.pendingTracks.length,
items: formattedItems, items: formattedItems,
@@ -210,7 +225,7 @@ router.get("/:id", async (req, res) => {
mergedItems, mergedItems,
}); });
} catch (error) { } catch (error) {
console.error("Get playlist error:", error); logger.error("Get playlist error:", error);
res.status(500).json({ error: "Failed to get playlist" }); res.status(500).json({ error: "Failed to get playlist" });
} }
}); });
@@ -218,6 +233,9 @@ router.get("/:id", async (req, res) => {
// PUT /playlists/:id // PUT /playlists/:id
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body); const data = createPlaylistSchema.parse(req.body);
@@ -249,7 +267,7 @@ router.put("/:id", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: error.errors }); .json({ error: "Invalid request", details: error.errors });
} }
console.error("Update playlist error:", error); logger.error("Update playlist error:", error);
res.status(500).json({ error: "Failed to update playlist" }); res.status(500).json({ error: "Failed to update playlist" });
} }
}); });
@@ -257,6 +275,9 @@ router.put("/:id", async (req, res) => {
// POST /playlists/:id/hide - Hide any playlist from your view // POST /playlists/:id/hide - Hide any playlist from your view
router.post("/:id/hide", async (req, res) => { router.post("/:id/hide", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
const playlistId = req.params.id; const playlistId = req.params.id;
@@ -285,7 +306,7 @@ router.post("/:id/hide", async (req, res) => {
res.json({ message: "Playlist hidden", isHidden: true }); res.json({ message: "Playlist hidden", isHidden: true });
} catch (error) { } catch (error) {
console.error("Hide playlist error:", error); logger.error("Hide playlist error:", error);
res.status(500).json({ error: "Failed to hide playlist" }); res.status(500).json({ error: "Failed to hide playlist" });
} }
}); });
@@ -293,6 +314,9 @@ router.post("/:id/hide", async (req, res) => {
// DELETE /playlists/:id/hide - Unhide a shared playlist // DELETE /playlists/:id/hide - Unhide a shared playlist
router.delete("/:id/hide", async (req, res) => { router.delete("/:id/hide", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
const playlistId = req.params.id; const playlistId = req.params.id;
@@ -303,7 +327,7 @@ router.delete("/:id/hide", async (req, res) => {
res.json({ message: "Playlist unhidden", isHidden: false }); res.json({ message: "Playlist unhidden", isHidden: false });
} catch (error) { } catch (error) {
console.error("Unhide playlist error:", error); logger.error("Unhide playlist error:", error);
res.status(500).json({ error: "Failed to unhide playlist" }); res.status(500).json({ error: "Failed to unhide playlist" });
} }
}); });
@@ -311,6 +335,9 @@ router.delete("/:id/hide", async (req, res) => {
// DELETE /playlists/:id // DELETE /playlists/:id
router.delete("/:id", async (req, res) => { router.delete("/:id", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
// Check ownership // Check ownership
@@ -332,7 +359,7 @@ router.delete("/:id", async (req, res) => {
res.json({ message: "Playlist deleted" }); res.json({ message: "Playlist deleted" });
} catch (error) { } catch (error) {
console.error("Delete playlist error:", error); logger.error("Delete playlist error:", error);
res.status(500).json({ error: "Failed to delete playlist" }); res.status(500).json({ error: "Failed to delete playlist" });
} }
}); });
@@ -340,6 +367,7 @@ router.delete("/:id", async (req, res) => {
// POST /playlists/:id/items // POST /playlists/:id/items
router.post("/:id/items", async (req, res) => { router.post("/:id/items", async (req, res) => {
try { try {
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const userId = req.user.id; const userId = req.user.id;
const parsedBody = addTrackSchema.safeParse(req.body); const parsedBody = addTrackSchema.safeParse(req.body);
if (!parsedBody.success) { if (!parsedBody.success) {
@@ -425,7 +453,7 @@ router.post("/:id/items", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: error.errors }); .json({ error: "Invalid request", details: error.errors });
} }
console.error("Add track to playlist error:", error); logger.error("Add track to playlist error:", error);
res.status(500).json({ error: "Failed to add track to playlist" }); res.status(500).json({ error: "Failed to add track to playlist" });
} }
}); });
@@ -433,7 +461,7 @@ router.post("/:id/items", async (req, res) => {
// DELETE /playlists/:id/items/:trackId // DELETE /playlists/:id/items/:trackId
router.delete("/:id/items/:trackId", async (req, res) => { router.delete("/:id/items/:trackId", async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user!.id;
// Check ownership // Check ownership
const playlist = await prisma.playlist.findUnique({ const playlist = await prisma.playlist.findUnique({
@@ -459,7 +487,7 @@ router.delete("/:id/items/:trackId", async (req, res) => {
res.json({ message: "Track removed from playlist" }); res.json({ message: "Track removed from playlist" });
} catch (error) { } catch (error) {
console.error("Remove track from playlist error:", error); logger.error("Remove track from playlist error:", error);
res.status(500).json({ error: "Failed to remove track from playlist" }); res.status(500).json({ error: "Failed to remove track from playlist" });
} }
}); });
@@ -467,7 +495,7 @@ router.delete("/:id/items/:trackId", async (req, res) => {
// PUT /playlists/:id/items/reorder // PUT /playlists/:id/items/reorder
router.put("/:id/items/reorder", async (req, res) => { router.put("/:id/items/reorder", async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user!.id;
const { trackIds } = req.body; // Array of track IDs in new order const { trackIds } = req.body; // Array of track IDs in new order
if (!Array.isArray(trackIds)) { if (!Array.isArray(trackIds)) {
@@ -504,7 +532,7 @@ router.put("/:id/items/reorder", async (req, res) => {
res.json({ message: "Playlist reordered" }); res.json({ message: "Playlist reordered" });
} catch (error) { } catch (error) {
console.error("Reorder playlist error:", error); logger.error("Reorder playlist error:", error);
res.status(500).json({ error: "Failed to reorder playlist" }); res.status(500).json({ error: "Failed to reorder playlist" });
} }
}); });
@@ -519,7 +547,7 @@ router.put("/:id/items/reorder", async (req, res) => {
*/ */
router.get("/:id/pending", async (req, res) => { router.get("/:id/pending", async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user!.id;
const playlistId = req.params.id; const playlistId = req.params.id;
// Check ownership or public access // Check ownership or public access
@@ -553,7 +581,7 @@ router.get("/:id/pending", async (req, res) => {
spotifyPlaylistId: playlist.spotifyPlaylistId, spotifyPlaylistId: playlist.spotifyPlaylistId,
}); });
} catch (error) { } catch (error) {
console.error("Get pending tracks error:", error); logger.error("Get pending tracks error:", error);
res.status(500).json({ error: "Failed to get pending tracks" }); res.status(500).json({ error: "Failed to get pending tracks" });
} }
}); });
@@ -564,7 +592,7 @@ router.get("/:id/pending", async (req, res) => {
*/ */
router.delete("/:id/pending/:trackId", async (req, res) => { router.delete("/:id/pending/:trackId", async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user!.id;
const { id: playlistId, trackId: pendingTrackId } = req.params; const { id: playlistId, trackId: pendingTrackId } = req.params;
// Check ownership // Check ownership
@@ -589,7 +617,7 @@ router.delete("/:id/pending/:trackId", async (req, res) => {
if (error.code === "P2025") { if (error.code === "P2025") {
return res.status(404).json({ error: "Pending track not found" }); return res.status(404).json({ error: "Pending track not found" });
} }
console.error("Delete pending track error:", error); logger.error("Delete pending track error:", error);
res.status(500).json({ error: "Failed to delete pending track" }); res.status(500).json({ error: "Failed to delete pending track" });
} }
}); });
@@ -632,7 +660,7 @@ router.get("/:id/pending/:trackId/preview", async (req, res) => {
res.json({ previewUrl }); res.json({ previewUrl });
} catch (error: any) { } catch (error: any) {
console.error("Get preview URL error:", error); logger.error("Get preview URL error:", error);
res.status(500).json({ error: "Failed to get preview URL" }); res.status(500).json({ error: "Failed to get preview URL" });
} }
}); });
@@ -644,7 +672,7 @@ router.get("/:id/pending/:trackId/preview", async (req, res) => {
*/ */
router.post("/:id/pending/:trackId/retry", async (req, res) => { router.post("/:id/pending/:trackId/retry", async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user!.id;
const { id: playlistId, trackId: pendingTrackId } = req.params; const { id: playlistId, trackId: pendingTrackId } = req.params;
sessionLog( sessionLog(
@@ -771,7 +799,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
? pendingTrack.spotifyAlbum ? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist; // Use artist as fallback folder name : pendingTrack.spotifyArtist; // Use artist as fallback folder name
console.log( logger.debug(
`[Retry] Starting download for: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}` `[Retry] Starting download for: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
); );
sessionLog( sessionLog(
@@ -787,7 +815,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
); );
if (!searchResult.found || searchResult.allMatches.length === 0) { if (!searchResult.found || searchResult.allMatches.length === 0) {
console.log(`[Retry] No results found on Soulseek`); logger.debug(`[Retry] No results found on Soulseek`);
sessionLog("PENDING-RETRY", `No results found on Soulseek`, "INFO"); sessionLog("PENDING-RETRY", `No results found on Soulseek`, "INFO");
await prisma.downloadJob.update({ await prisma.downloadJob.update({
@@ -806,7 +834,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
}); });
} }
console.log( logger.debug(
`[Retry] ✓ Found ${searchResult.allMatches.length} results, starting download in background` `[Retry] ✓ Found ${searchResult.allMatches.length} results, starting download in background`
); );
sessionLog( sessionLog(
@@ -833,7 +861,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
) )
.then(async (result) => { .then(async (result) => {
if (result.success) { if (result.success) {
console.log( logger.debug(
`[Retry] ✓ Download complete: ${result.filePath}` `[Retry] ✓ Download complete: ${result.filePath}`
); );
sessionLog( sessionLog(
@@ -870,7 +898,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
removeOnComplete: true, removeOnComplete: true,
} }
); );
console.log( logger.debug(
`[Retry] Queued library scan to reconcile pending tracks` `[Retry] Queued library scan to reconcile pending tracks`
); );
sessionLog( sessionLog(
@@ -880,7 +908,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
})` })`
); );
} catch (scanError) { } catch (scanError) {
console.error( logger.error(
`[Retry] Failed to queue scan:`, `[Retry] Failed to queue scan:`,
scanError scanError
); );
@@ -893,7 +921,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
); );
} }
} else { } else {
console.log(`[Retry] Download failed: ${result.error}`); logger.debug(`[Retry] Download failed: ${result.error}`);
sessionLog( sessionLog(
"PENDING-RETRY", "PENDING-RETRY",
`Download failed: ${result.error || "unknown error"}`, `Download failed: ${result.error || "unknown error"}`,
@@ -911,7 +939,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
} }
}) })
.catch((error) => { .catch((error) => {
console.error(`[Retry] Download error:`, error); logger.error(`[Retry] Download error:`, error);
sessionLog( sessionLog(
"PENDING-RETRY", "PENDING-RETRY",
`Download exception: ${error?.message || error}`, `Download exception: ${error?.message || error}`,
@@ -930,7 +958,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
.catch(() => undefined); .catch(() => undefined);
}); });
} catch (error: any) { } catch (error: any) {
console.error("Retry pending track error:", error); logger.error("Retry pending track error:", error);
sessionLog( sessionLog(
"PENDING-RETRY", "PENDING-RETRY",
`Handler error: ${error?.message || error}`, `Handler error: ${error?.message || error}`,
@@ -949,7 +977,7 @@ router.post("/:id/pending/:trackId/retry", async (req, res) => {
*/ */
router.post("/:id/pending/reconcile", async (req, res) => { router.post("/:id/pending/reconcile", async (req, res) => {
try { try {
const userId = req.user.id; const userId = req.user!.id;
const playlistId = req.params.id; const playlistId = req.params.id;
// Check ownership // Check ownership
@@ -977,7 +1005,7 @@ router.post("/:id/pending/reconcile", async (req, res) => {
playlistsUpdated: result.playlistsUpdated, playlistsUpdated: result.playlistsUpdated,
}); });
} catch (error) { } catch (error) {
console.error("Reconcile pending tracks error:", error); logger.error("Reconcile pending tracks error:", error);
res.status(500).json({ error: "Failed to reconcile pending tracks" }); res.status(500).json({ error: "Failed to reconcile pending tracks" });
} }
}); });
+3 -2
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod"; import { z } from "zod";
@@ -40,7 +41,7 @@ router.post("/", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid request", details: error.errors }); .json({ error: "Invalid request", details: error.errors });
} }
console.error("Create play error:", error); logger.error("Create play error:", error);
res.status(500).json({ error: "Failed to log play" }); res.status(500).json({ error: "Failed to log play" });
} }
}); });
@@ -76,7 +77,7 @@ router.get("/", async (req, res) => {
res.json(plays); res.json(plays);
} catch (error) { } catch (error) {
console.error("Get plays error:", error); logger.error("Get plays error:", error);
res.status(500).json({ error: "Failed to get plays" }); res.status(500).json({ error: "Failed to get plays" });
} }
}); });
+102 -101
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAuthOrToken } from "../middleware/auth"; import { requireAuth, requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { rssParserService } from "../services/rss-parser"; import { rssParserService } from "../services/rss-parser";
@@ -16,7 +17,7 @@ const router = Router();
router.post("/sync-covers", requireAuth, async (req, res) => { router.post("/sync-covers", requireAuth, async (req, res) => {
try { try {
const { notificationService } = await import("../services/notificationService"); const { notificationService } = await import("../services/notificationService");
console.log(" Starting podcast cover sync..."); logger.debug(" Starting podcast cover sync...");
const podcastResult = await podcastCacheService.syncAllCovers(); const podcastResult = await podcastCacheService.syncAllCovers();
const episodeResult = await podcastCacheService.syncEpisodeCovers(); const episodeResult = await podcastCacheService.syncEpisodeCovers();
@@ -25,7 +26,7 @@ router.post("/sync-covers", requireAuth, async (req, res) => {
await notificationService.notifySystem( await notificationService.notifySystem(
req.user!.id, req.user!.id,
"Podcast Covers Synced", "Podcast Covers Synced",
`Synced ${podcastResult.cached || 0} podcast covers and ${episodeResult.cached || 0} episode covers` `Synced ${podcastResult.synced || 0} podcast covers and ${episodeResult.synced || 0} episode covers`
); );
res.json({ res.json({
@@ -34,7 +35,7 @@ router.post("/sync-covers", requireAuth, async (req, res) => {
episodes: episodeResult, episodes: episodeResult,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Podcast cover sync failed:", error); logger.error("Podcast cover sync failed:", error);
res.status(500).json({ res.status(500).json({
error: "Sync failed", error: "Sync failed",
message: error.message, message: error.message,
@@ -110,7 +111,7 @@ router.get("/", async (req, res) => {
res.json(podcasts); res.json(podcasts);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching podcasts:", error); logger.error("Error fetching podcasts:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch podcasts", error: "Failed to fetch podcasts",
message: error.message, message: error.message,
@@ -127,7 +128,7 @@ router.get("/discover/top", requireAuthOrToken, async (req, res) => {
const { limit = "20" } = req.query; const { limit = "20" } = req.query;
const podcastLimit = Math.min(parseInt(limit as string, 10), 50); const podcastLimit = Math.min(parseInt(limit as string, 10), 50);
console.log(`\n[TOP PODCASTS] Request (limit: ${podcastLimit})`); logger.debug(`\n[TOP PODCASTS] Request (limit: ${podcastLimit})`);
// Simple iTunes search - same as the working search bar! // Simple iTunes search - same as the working search bar!
const itunesResponse = await axios.get( const itunesResponse = await axios.get(
@@ -155,10 +156,10 @@ router.get("/discover/top", requireAuthOrToken, async (req, res) => {
isExternal: true, isExternal: true,
})); }));
console.log(` Found ${podcasts.length} podcasts`); logger.debug(` Found ${podcasts.length} podcasts`);
res.json(podcasts); res.json(podcasts);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching top podcasts:", error); logger.error("Error fetching top podcasts:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch top podcasts", error: "Failed to fetch top podcasts",
message: error.message, message: error.message,
@@ -174,7 +175,7 @@ router.get("/discover/genres", async (req, res) => {
try { try {
const { genres } = req.query; // Comma-separated genre IDs const { genres } = req.query; // Comma-separated genre IDs
console.log(`\n[GENRE PODCASTS] Request (genres: ${genres})`); logger.debug(`\n[GENRE PODCASTS] Request (genres: ${genres})`);
if (!genres || typeof genres !== "string") { if (!genres || typeof genres !== "string") {
return res.status(400).json({ return res.status(400).json({
@@ -198,7 +199,7 @@ router.get("/discover/genres", async (req, res) => {
// Fetch podcasts for each genre using simple iTunes search - PARALLEL execution // Fetch podcasts for each genre using simple iTunes search - PARALLEL execution
const genreFetchPromises = genreIds.map(async (genreId) => { const genreFetchPromises = genreIds.map(async (genreId) => {
const searchTerm = genreSearchTerms[genreId] || "podcast"; const searchTerm = genreSearchTerms[genreId] || "podcast";
console.log(` Searching for "${searchTerm}"...`); logger.debug(` Searching for "${searchTerm}"...`);
try { try {
// Simple iTunes search - same as the working search bar! // Simple iTunes search - same as the working search bar!
@@ -230,12 +231,12 @@ router.get("/discover/genres", async (req, res) => {
}) })
); );
console.log( logger.debug(
` Found ${podcasts.length} podcasts for genre ${genreId}` ` Found ${podcasts.length} podcasts for genre ${genreId}`
); );
return { genreId, podcasts }; return { genreId, podcasts };
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
` Error searching for ${searchTerm}:`, ` Error searching for ${searchTerm}:`,
error.message error.message
); );
@@ -252,12 +253,12 @@ router.get("/discover/genres", async (req, res) => {
results[genreId] = podcasts; results[genreId] = podcasts;
} }
console.log( logger.debug(
` Fetched podcasts for ${genreIds.length} genres (parallel)` ` Fetched podcasts for ${genreIds.length} genres (parallel)`
); );
res.json(results); res.json(results);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching genre podcasts:", error); logger.error("Error fetching genre podcasts:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch genre podcasts", error: "Failed to fetch genre podcasts",
message: error.message, message: error.message,
@@ -277,7 +278,7 @@ router.get("/discover/genre/:genreId", async (req, res) => {
const podcastLimit = Math.min(parseInt(limit as string, 10), 50); const podcastLimit = Math.min(parseInt(limit as string, 10), 50);
const podcastOffset = parseInt(offset as string, 10); const podcastOffset = parseInt(offset as string, 10);
console.log( logger.debug(
`\n[GENRE PAGINATED] Request (genre: ${genreId}, limit: ${podcastLimit}, offset: ${podcastOffset})` `\n[GENRE PAGINATED] Request (genre: ${genreId}, limit: ${podcastLimit}, offset: ${podcastOffset})`
); );
@@ -293,7 +294,7 @@ router.get("/discover/genre/:genreId", async (req, res) => {
}; };
const searchTerm = genreSearchTerms[genreId] || "podcast"; const searchTerm = genreSearchTerms[genreId] || "podcast";
console.log( logger.debug(
` Searching for "${searchTerm}" (offset: ${podcastOffset})...` ` Searching for "${searchTerm}" (offset: ${podcastOffset})...`
); );
@@ -332,12 +333,12 @@ router.get("/discover/genre/:genreId", async (req, res) => {
podcastOffset + podcastLimit podcastOffset + podcastLimit
); );
console.log( logger.debug(
` Found ${podcasts.length} podcasts (total available: ${allPodcasts.length})` ` Found ${podcasts.length} podcasts (total available: ${allPodcasts.length})`
); );
res.json(podcasts); res.json(podcasts);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching paginated genre podcasts:", error); logger.error("Error fetching paginated genre podcasts:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch podcasts", error: "Failed to fetch podcasts",
message: error.message, message: error.message,
@@ -354,7 +355,7 @@ router.get("/preview/:itunesId", async (req, res) => {
try { try {
const { itunesId } = req.params; const { itunesId } = req.params;
console.log(`\n [PODCAST PREVIEW] iTunes ID: ${itunesId}`); logger.debug(`\n [PODCAST PREVIEW] iTunes ID: ${itunesId}`);
// Try to fetch from iTunes API // Try to fetch from iTunes API
const itunesResponse = await axios.get( const itunesResponse = await axios.get(
@@ -406,7 +407,7 @@ router.get("/preview/:itunesId", async (req, res) => {
podcastData.feedUrl podcastData.feedUrl
); );
description = description =
feedData.description || feedData.itunes?.summary || ""; feedData.podcast.description || "";
// Get first 3 episodes for preview // Get first 3 episodes for preview
previewEpisodes = (feedData.episodes || []) previewEpisodes = (feedData.episodes || [])
@@ -417,11 +418,11 @@ router.get("/preview/:itunesId", async (req, res) => {
duration: episode.duration || 0, duration: episode.duration || 0,
})); }));
console.log( logger.debug(
` [PODCAST PREVIEW] Fetched description (${description.length} chars) and ${previewEpisodes.length} preview episodes` ` [PODCAST PREVIEW] Fetched description (${description.length} chars) and ${previewEpisodes.length} preview episodes`
); );
} catch (error) { } catch (error) {
console.warn(` Failed to fetch RSS feed for preview:`, error); logger.warn(` Failed to fetch RSS feed for preview:`, error);
// Continue without description and episodes // Continue without description and episodes
} }
} }
@@ -440,7 +441,7 @@ router.get("/preview/:itunesId", async (req, res) => {
subscribedPodcastId: isSubscribed ? existingPodcast!.id : null, subscribedPodcastId: isSubscribed ? existingPodcast!.id : null,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error previewing podcast:", error); logger.error("Error previewing podcast:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to preview podcast", error: "Failed to preview podcast",
message: error.message, message: error.message,
@@ -532,7 +533,7 @@ router.get("/:id", async (req, res) => {
isSubscribed: true, isSubscribed: true,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error fetching podcast:", error); logger.error("Error fetching podcast:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch podcast", error: "Failed to fetch podcast",
message: error.message, message: error.message,
@@ -554,17 +555,17 @@ router.post("/subscribe", async (req, res) => {
.json({ error: "feedUrl or itunesId is required" }); .json({ error: "feedUrl or itunesId is required" });
} }
console.log( logger.debug(
`\n [PODCAST] Subscribe request from ${req.user!.username}` `\n [PODCAST] Subscribe request from ${req.user!.username}`
); );
console.log(` Feed URL: ${feedUrl || "N/A"}`); logger.debug(` Feed URL: ${feedUrl || "N/A"}`);
console.log(` iTunes ID: ${itunesId || "N/A"}`); logger.debug(` iTunes ID: ${itunesId || "N/A"}`);
let finalFeedUrl = feedUrl; let finalFeedUrl = feedUrl;
// If only iTunes ID provided, fetch feed URL from iTunes API // If only iTunes ID provided, fetch feed URL from iTunes API
if (!finalFeedUrl && itunesId) { if (!finalFeedUrl && itunesId) {
console.log(` Looking up feed URL from iTunes...`); logger.debug(` Looking up feed URL from iTunes...`);
const itunesResponse = await axios.get( const itunesResponse = await axios.get(
"https://itunes.apple.com/lookup", "https://itunes.apple.com/lookup",
{ {
@@ -582,7 +583,7 @@ router.post("/subscribe", async (req, res) => {
} }
finalFeedUrl = itunesResponse.data.results[0].feedUrl; finalFeedUrl = itunesResponse.data.results[0].feedUrl;
console.log(` Found feed URL: ${finalFeedUrl}`); logger.debug(` Found feed URL: ${finalFeedUrl}`);
} }
// Check if podcast already exists in database // Check if podcast already exists in database
@@ -591,7 +592,7 @@ router.post("/subscribe", async (req, res) => {
}); });
if (podcast) { if (podcast) {
console.log(` Podcast exists in database: ${podcast.title}`); logger.debug(` Podcast exists in database: ${podcast.title}`);
// Check if user is already subscribed // Check if user is already subscribed
const existingSubscription = const existingSubscription =
@@ -605,7 +606,7 @@ router.post("/subscribe", async (req, res) => {
}); });
if (existingSubscription) { if (existingSubscription) {
console.log(` User already subscribed`); logger.debug(` User already subscribed`);
return res.json({ return res.json({
success: true, success: true,
podcast: { podcast: {
@@ -624,7 +625,7 @@ router.post("/subscribe", async (req, res) => {
}, },
}); });
console.log(` User subscribed to existing podcast`); logger.debug(` User subscribed to existing podcast`);
return res.json({ return res.json({
success: true, success: true,
podcast: { podcast: {
@@ -636,14 +637,14 @@ router.post("/subscribe", async (req, res) => {
} }
// Parse RSS feed to get podcast and episodes // Parse RSS feed to get podcast and episodes
console.log(` Parsing RSS feed...`); logger.debug(` Parsing RSS feed...`);
const { podcast: podcastData, episodes } = const { podcast: podcastData, episodes } =
await rssParserService.parseFeed(finalFeedUrl); await rssParserService.parseFeed(finalFeedUrl);
// Create podcast in database // Create podcast in database
console.log(` Saving podcast to database...`); logger.debug(` Saving podcast to database...`);
const finalItunesId = itunesId || podcastData.itunesId; const finalItunesId = itunesId || podcastData.itunesId;
console.log(` iTunes ID to save: ${finalItunesId || "NONE"}`); logger.debug(` iTunes ID to save: ${finalItunesId || "NONE"}`);
podcast = await prisma.podcast.create({ podcast = await prisma.podcast.create({
data: { data: {
@@ -659,11 +660,11 @@ router.post("/subscribe", async (req, res) => {
}, },
}); });
console.log(` Podcast created: ${podcast.id}`); logger.debug(` Podcast created: ${podcast.id}`);
console.log(` iTunes ID saved: ${podcast.itunesId || "NONE"}`); logger.debug(` iTunes ID saved: ${podcast.itunesId || "NONE"}`);
// Save episodes // Save episodes
console.log(` Saving ${episodes.length} episodes...`); logger.debug(` Saving ${episodes.length} episodes...`);
await prisma.podcastEpisode.createMany({ await prisma.podcastEpisode.createMany({
data: episodes.map((ep) => ({ data: episodes.map((ep) => ({
podcastId: podcast!.id, podcastId: podcast!.id,
@@ -682,7 +683,7 @@ router.post("/subscribe", async (req, res) => {
skipDuplicates: true, skipDuplicates: true,
}); });
console.log(` Episodes saved`); logger.debug(` Episodes saved`);
// Subscribe user // Subscribe user
await prisma.podcastSubscription.create({ await prisma.podcastSubscription.create({
@@ -692,7 +693,7 @@ router.post("/subscribe", async (req, res) => {
}, },
}); });
console.log(` User subscribed successfully`); logger.debug(` User subscribed successfully`);
res.json({ res.json({
success: true, success: true,
@@ -703,7 +704,7 @@ router.post("/subscribe", async (req, res) => {
message: "Subscribed successfully", message: "Subscribed successfully",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error subscribing to podcast:", error); logger.error("Error subscribing to podcast:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to subscribe to podcast", error: "Failed to subscribe to podcast",
message: error.message, message: error.message,
@@ -719,9 +720,9 @@ router.delete("/:id/unsubscribe", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
console.log(`\n[PODCAST] Unsubscribe request`); logger.debug(`\n[PODCAST] Unsubscribe request`);
console.log(` User: ${req.user!.username}`); logger.debug(` User: ${req.user!.username}`);
console.log(` Podcast ID: ${id}`); logger.debug(` Podcast ID: ${id}`);
// Delete subscription // Delete subscription
const deleted = await prisma.podcastSubscription.deleteMany({ const deleted = await prisma.podcastSubscription.deleteMany({
@@ -757,14 +758,14 @@ router.delete("/:id/unsubscribe", async (req, res) => {
}, },
}); });
console.log(` Unsubscribed successfully`); logger.debug(` Unsubscribed successfully`);
res.json({ res.json({
success: true, success: true,
message: "Unsubscribed successfully", message: "Unsubscribed successfully",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error unsubscribing from podcast:", error); logger.error("Error unsubscribing from podcast:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to unsubscribe", error: "Failed to unsubscribe",
message: error.message, message: error.message,
@@ -780,8 +781,8 @@ router.get("/:id/refresh", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
console.log(`\n [PODCAST] Refresh request`); logger.debug(`\n [PODCAST] Refresh request`);
console.log(` Podcast ID: ${id}`); logger.debug(` Podcast ID: ${id}`);
const podcast = await prisma.podcast.findUnique({ const podcast = await prisma.podcast.findUnique({
where: { id }, where: { id },
@@ -792,7 +793,7 @@ router.get("/:id/refresh", async (req, res) => {
} }
// Parse RSS feed // Parse RSS feed
console.log(` Parsing RSS feed...`); logger.debug(` Parsing RSS feed...`);
const { podcast: podcastData, episodes } = const { podcast: podcastData, episodes } =
await rssParserService.parseFeed(podcast.feedUrl); await rssParserService.parseFeed(podcast.feedUrl);
@@ -844,7 +845,7 @@ router.get("/:id/refresh", async (req, res) => {
} }
} }
console.log( logger.debug(
` Refresh complete. ${newEpisodesCount} new episodes added.` ` Refresh complete. ${newEpisodesCount} new episodes added.`
); );
@@ -855,7 +856,7 @@ router.get("/:id/refresh", async (req, res) => {
message: `Found ${newEpisodesCount} new episodes`, message: `Found ${newEpisodesCount} new episodes`,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error refreshing podcast:", error); logger.error("Error refreshing podcast:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to refresh podcast", error: "Failed to refresh podcast",
message: error.message, message: error.message,
@@ -888,7 +889,7 @@ router.get("/:podcastId/episodes/:episodeId/cache-status", async (req, res) => {
path: cachedPath ? true : false, // Don't expose actual path path: cachedPath ? true : false, // Don't expose actual path
}); });
} catch (error: any) { } catch (error: any) {
console.error("[PODCAST] Cache status check failed:", error); logger.error("[PODCAST] Cache status check failed:", error);
res.status(500).json({ error: "Failed to check cache status" }); res.status(500).json({ error: "Failed to check cache status" });
} }
}); });
@@ -904,12 +905,12 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const userId = req.user?.id; const userId = req.user?.id;
const podcastDebug = process.env.PODCAST_DEBUG === "1"; const podcastDebug = process.env.PODCAST_DEBUG === "1";
console.log(`\n [PODCAST STREAM] Request:`); logger.debug(`\n [PODCAST STREAM] Request:`);
console.log(` Podcast ID: ${podcastId}`); logger.debug(` Podcast ID: ${podcastId}`);
console.log(` Episode ID: ${episodeId}`); logger.debug(` Episode ID: ${episodeId}`);
if (podcastDebug) { if (podcastDebug) {
console.log(` Range: ${req.headers.range || "none"}`); logger.debug(` Range: ${req.headers.range || "none"}`);
console.log(` UA: ${req.headers["user-agent"] || "unknown"}`); logger.debug(` UA: ${req.headers["user-agent"] || "unknown"}`);
} }
const episode = await prisma.podcastEpisode.findUnique({ const episode = await prisma.podcastEpisode.findUnique({
@@ -921,10 +922,10 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
} }
if (podcastDebug) { if (podcastDebug) {
console.log(` Episode DB: title="${episode.title}"`); logger.debug(` Episode DB: title="${episode.title}"`);
console.log(` Episode DB: guid="${episode.guid}"`); logger.debug(` Episode DB: guid="${episode.guid}"`);
console.log(` Episode DB: audioUrl="${episode.audioUrl}"`); logger.debug(` Episode DB: audioUrl="${episode.audioUrl}"`);
console.log(` Episode DB: mimeType="${episode.mimeType || "unknown"}" fileSize=${episode.fileSize || 0}`); logger.debug(` Episode DB: mimeType="${episode.mimeType || "unknown"}" fileSize=${episode.fileSize || 0}`);
} }
const range = req.headers.range; const range = req.headers.range;
@@ -937,12 +938,12 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const cachedPath = await getCachedFilePath(episodeId); const cachedPath = await getCachedFilePath(episodeId);
if (cachedPath) { if (cachedPath) {
console.log(` Streaming from cache: ${cachedPath}`); logger.debug(` Streaming from cache: ${cachedPath}`);
try { try {
const stats = await fs.promises.stat(cachedPath); const stats = await fs.promises.stat(cachedPath);
const fileSize = stats.size; const fileSize = stats.size;
if (podcastDebug) { if (podcastDebug) {
console.log(` Cache file size: ${fileSize}`); logger.debug(` Cache file size: ${fileSize}`);
} }
if (fileSize === 0) { if (fileSize === 0) {
@@ -958,7 +959,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
// Validate range bounds // Validate range bounds
if (start >= fileSize) { if (start >= fileSize) {
console.log( logger.debug(
` Range start ${start} >= file size ${fileSize}, clamping to EOF` ` Range start ${start} >= file size ${fileSize}, clamping to EOF`
); );
// Browsers can occasionally request a range start beyond EOF during media seeking. // Browsers can occasionally request a range start beyond EOF during media seeking.
@@ -987,7 +988,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}); });
fileStream.pipe(res); fileStream.pipe(res);
fileStream.on("error", (err) => { fileStream.on("error", (err) => {
console.error(" Cache stream error:", err); logger.error(" Cache stream error:", err);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: "Failed to stream episode", error: "Failed to stream episode",
@@ -1002,7 +1003,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const validEnd = Math.min(end, fileSize - 1); const validEnd = Math.min(end, fileSize - 1);
const chunkSize = validEnd - start + 1; const chunkSize = validEnd - start + 1;
console.log( logger.debug(
` Serving range: bytes ${start}-${validEnd}/${fileSize}` ` Serving range: bytes ${start}-${validEnd}/${fileSize}`
); );
@@ -1029,7 +1030,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}); });
fileStream.pipe(res); fileStream.pipe(res);
fileStream.on("error", (err) => { fileStream.on("error", (err) => {
console.error(" Cache stream error:", err); logger.error(" Cache stream error:", err);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: "Failed to stream episode", error: "Failed to stream episode",
@@ -1042,7 +1043,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
} }
// No range - serve entire file // No range - serve entire file
console.log(` Serving full file: ${fileSize} bytes`); logger.debug(` Serving full file: ${fileSize} bytes`);
res.writeHead(200, { res.writeHead(200, {
"Content-Type": episode.mimeType || "audio/mpeg", "Content-Type": episode.mimeType || "audio/mpeg",
"Content-Length": fileSize, "Content-Length": fileSize,
@@ -1061,7 +1062,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}); });
fileStream.pipe(res); fileStream.pipe(res);
fileStream.on("error", (err) => { fileStream.on("error", (err) => {
console.error(" Cache stream error:", err); logger.error(" Cache stream error:", err);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: "Failed to stream episode", error: "Failed to stream episode",
@@ -1072,7 +1073,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}); });
return; // CRITICAL: Exit after starting cache stream return; // CRITICAL: Exit after starting cache stream
} catch (err: any) { } catch (err: any) {
console.error( logger.error(
" Failed to stream from cache, falling back to RSS:", " Failed to stream from cache, falling back to RSS:",
err.message err.message
); );
@@ -1082,12 +1083,12 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
// Not cached yet - trigger background download while streaming from RSS // Not cached yet - trigger background download while streaming from RSS
if (userId && !isDownloading(episodeId)) { if (userId && !isDownloading(episodeId)) {
console.log(` Triggering background download for caching`); logger.debug(` Triggering background download for caching`);
downloadInBackground(episodeId, episode.audioUrl, userId); downloadInBackground(episodeId, episode.audioUrl, userId);
} }
// Stream from RSS URL // Stream from RSS URL
console.log(` Streaming from RSS: ${episode.audioUrl}`); logger.debug(` Streaming from RSS: ${episode.audioUrl}`);
// Get file size first for proper range handling // Get file size first for proper range handling
let fileSize = episode.fileSize; let fileSize = episode.fileSize;
@@ -1104,7 +1105,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
}); });
} }
} catch (err) { } catch (err) {
console.warn(" Could not get file size via HEAD request"); logger.warn(" Could not get file size via HEAD request");
} }
} }
@@ -1115,7 +1116,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1; const chunkSize = end - start + 1;
console.log(` Range request: bytes=${start}-${end}/${fileSize}`); logger.debug(` Range request: bytes=${start}-${end}/${fileSize}`);
try { try {
// Try range request first // Try range request first
@@ -1149,7 +1150,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
} catch (rangeError: any) { } catch (rangeError: any) {
// 416 = Range Not Satisfiable - many podcast CDNs don't support range requests // 416 = Range Not Satisfiable - many podcast CDNs don't support range requests
// Fall back to streaming the full file and let the browser handle seeking // Fall back to streaming the full file and let the browser handle seeking
console.log( logger.debug(
` Range request failed (${ ` Range request failed (${
rangeError.response?.status || rangeError.message rangeError.response?.status || rangeError.message
}), falling back to full stream` }), falling back to full stream`
@@ -1183,7 +1184,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
} }
} else { } else {
// No range request - stream entire file // No range request - stream entire file
console.log(` Streaming full file`); logger.debug(` Streaming full file`);
const response = await axios.get(episode.audioUrl, { const response = await axios.get(episode.audioUrl, {
responseType: "stream", responseType: "stream",
@@ -1209,7 +1210,7 @@ router.get("/:podcastId/episodes/:episodeId/stream", async (req, res) => {
response.data.pipe(res); response.data.pipe(res);
} }
} catch (error: any) { } catch (error: any) {
console.error("\n [PODCAST STREAM] Error:", error.message); logger.error("\n [PODCAST STREAM] Error:", error.message);
if (!res.headersSent) { if (!res.headersSent) {
res.status(500).json({ res.status(500).json({
error: "Failed to stream episode", error: "Failed to stream episode",
@@ -1228,12 +1229,12 @@ router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
const { podcastId, episodeId } = req.params; const { podcastId, episodeId } = req.params;
const { currentTime, duration, isFinished } = req.body; const { currentTime, duration, isFinished } = req.body;
console.log(`\n [PODCAST PROGRESS] Update:`); logger.debug(`\n [PODCAST PROGRESS] Update:`);
console.log(` User: ${req.user!.username}`); logger.debug(` User: ${req.user!.username}`);
console.log(` Episode ID: ${episodeId}`); logger.debug(` Episode ID: ${episodeId}`);
console.log(` Current Time: ${currentTime}s`); logger.debug(` Current Time: ${currentTime}s`);
console.log(` Duration: ${duration}s`); logger.debug(` Duration: ${duration}s`);
console.log(` Finished: ${isFinished}`); logger.debug(` Finished: ${isFinished}`);
const progress = await prisma.podcastProgress.upsert({ const progress = await prisma.podcastProgress.upsert({
where: { where: {
@@ -1257,7 +1258,7 @@ router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
}, },
}); });
console.log(` Progress saved`); logger.debug(` Progress saved`);
res.json({ res.json({
success: true, success: true,
@@ -1271,7 +1272,7 @@ router.post("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
}, },
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error updating progress:", error); logger.error("Error updating progress:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to update progress", error: "Failed to update progress",
message: error.message, message: error.message,
@@ -1287,9 +1288,9 @@ router.delete("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
try { try {
const { episodeId } = req.params; const { episodeId } = req.params;
console.log(`\n[PODCAST PROGRESS] Delete:`); logger.debug(`\n[PODCAST PROGRESS] Delete:`);
console.log(` User: ${req.user!.username}`); logger.debug(` User: ${req.user!.username}`);
console.log(` Episode ID: ${episodeId}`); logger.debug(` Episode ID: ${episodeId}`);
await prisma.podcastProgress.deleteMany({ await prisma.podcastProgress.deleteMany({
where: { where: {
@@ -1298,14 +1299,14 @@ router.delete("/:podcastId/episodes/:episodeId/progress", async (req, res) => {
}, },
}); });
console.log(` Progress removed`); logger.debug(` Progress removed`);
res.json({ res.json({
success: true, success: true,
message: "Progress removed", message: "Progress removed",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error removing progress:", error); logger.error("Error removing progress:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to remove progress", error: "Failed to remove progress",
message: error.message, message: error.message,
@@ -1329,7 +1330,7 @@ router.get("/:id/similar", async (req, res) => {
return res.status(404).json({ error: "Podcast not found" }); return res.status(404).json({ error: "Podcast not found" });
} }
console.log(`\n [SIMILAR PODCASTS] Request for: ${podcast.title}`); logger.debug(`\n [SIMILAR PODCASTS] Request for: ${podcast.title}`);
try { try {
// Check cache first // Check cache first
@@ -1344,7 +1345,7 @@ router.get("/:id/similar", async (req, res) => {
}); });
if (cachedRecommendations.length > 0) { if (cachedRecommendations.length > 0) {
console.log( logger.debug(
` Using ${cachedRecommendations.length} cached recommendations` ` Using ${cachedRecommendations.length} cached recommendations`
); );
return res.json( return res.json(
@@ -1364,15 +1365,15 @@ router.get("/:id/similar", async (req, res) => {
} }
// Fetch from iTunes Search API // Fetch from iTunes Search API
console.log(` Fetching from iTunes Search API...`); logger.debug(` Fetching from iTunes Search API...`);
const { itunesService } = await import("../services/itunes"); const { itunesService } = await import("../services/itunes");
const recommendations = await itunesService.getSimilarPodcasts( const recommendations = await itunesService.getSimilarPodcasts(
podcast.title, podcast.title,
podcast.description || undefined, podcast.description ?? undefined,
podcast.author podcast.author ?? undefined
); );
console.log(` Found ${recommendations.length} similar podcasts`); logger.debug(` Found ${recommendations.length} similar podcasts`);
if (recommendations.length > 0) { if (recommendations.length > 0) {
// Cache recommendations // Cache recommendations
@@ -1400,7 +1401,7 @@ router.get("/:id/similar", async (req, res) => {
})), })),
}); });
console.log( logger.debug(
` Cached ${recommendations.length} recommendations` ` Cached ${recommendations.length} recommendations`
); );
@@ -1420,14 +1421,14 @@ router.get("/:id/similar", async (req, res) => {
); );
} }
} catch (error: any) { } catch (error: any) {
console.warn(" iTunes search failed:", error.message); logger.warn(" iTunes search failed:", error.message);
} }
// No recommendations available // No recommendations available
console.log(` No recommendations found`); logger.debug(` No recommendations found`);
res.json([]); res.json([]);
} catch (error: any) { } catch (error: any) {
console.error("Error fetching similar podcasts:", error); logger.error("Error fetching similar podcasts:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to fetch similar podcasts", error: "Failed to fetch similar podcasts",
message: error.message, message: error.message,
@@ -1488,7 +1489,7 @@ router.get("/:id/cover", async (req, res) => {
res.status(404).json({ error: "Cover not found" }); res.status(404).json({ error: "Cover not found" });
} catch (error: any) { } catch (error: any) {
console.error("Error serving podcast cover:", error); logger.error("Error serving podcast cover:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to serve cover", error: "Failed to serve cover",
message: error.message, message: error.message,
@@ -1549,7 +1550,7 @@ router.get("/episodes/:episodeId/cover", async (req, res) => {
res.status(404).json({ error: "Cover not found" }); res.status(404).json({ error: "Cover not found" });
} catch (error: any) { } catch (error: any) {
console.error("Error serving episode cover:", error); logger.error("Error serving episode cover:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to serve cover", error: "Failed to serve cover",
message: error.message, message: error.message,
+8 -7
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAuthOrToken } from "../middleware/auth"; import { requireAuth, requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { lastFmService } from "../services/lastfm"; import { lastFmService } from "../services/lastfm";
@@ -93,7 +94,7 @@ router.get("/for-you", async (req, res) => {
}); });
const ownedArtistIds = new Set(ownedArtists.map((a) => a.artistId)); const ownedArtistIds = new Set(ownedArtists.map((a) => a.artistId));
console.log( logger.debug(
`Filtering recommendations: ${ownedArtistIds.size} owned artists to exclude` `Filtering recommendations: ${ownedArtistIds.size} owned artists to exclude`
); );
@@ -158,11 +159,11 @@ router.get("/for-you", async (req, res) => {
}; };
}); });
console.log( logger.debug(
`Recommendations: Found ${artistsWithMetadata.length} new artists` `Recommendations: Found ${artistsWithMetadata.length} new artists`
); );
artistsWithMetadata.forEach((a) => { artistsWithMetadata.forEach((a) => {
console.log( logger.debug(
` ${a.name}: coverArt=${a.coverArt ? "YES" : "NO"}, albums=${ ` ${a.name}: coverArt=${a.coverArt ? "YES" : "NO"}, albums=${
a.albumCount a.albumCount
}` }`
@@ -171,7 +172,7 @@ router.get("/for-you", async (req, res) => {
res.json({ artists: artistsWithMetadata }); res.json({ artists: artistsWithMetadata });
} catch (error) { } catch (error) {
console.error("Get recommendations for you error:", error); logger.error("Get recommendations for you error:", error);
res.status(500).json({ error: "Failed to get recommendations" }); res.status(500).json({ error: "Failed to get recommendations" });
} }
}); });
@@ -244,7 +245,7 @@ router.get("/", async (req, res) => {
recommendations, recommendations,
}); });
} catch (error) { } catch (error) {
console.error("Get recommendations error:", error); logger.error("Get recommendations error:", error);
res.status(500).json({ error: "Failed to get recommendations" }); res.status(500).json({ error: "Failed to get recommendations" });
} }
}); });
@@ -363,7 +364,7 @@ router.get("/albums", async (req, res) => {
recommendations, recommendations,
}); });
} catch (error) { } catch (error) {
console.error("Get album recommendations error:", error); logger.error("Get album recommendations error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to get album recommendations", error: "Failed to get album recommendations",
}); });
@@ -459,7 +460,7 @@ router.get("/tracks", async (req, res) => {
recommendations, recommendations,
}); });
} catch (error) { } catch (error) {
console.error("Get track recommendations error:", error); logger.error("Get track recommendations error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to get track recommendations", error: "Failed to get track recommendations",
}); });
+16 -24
View File
@@ -1,6 +1,8 @@
import { logger } from "../utils/logger";
/** /**
* Release Radar API * Release Radar API
* *
* Provides upcoming and recent releases from: * Provides upcoming and recent releases from:
* 1. Lidarr monitored artists (via calendar API) * 1. Lidarr monitored artists (via calendar API)
* 2. Similar artists from user's library (Last.fm similar artists) * 2. Similar artists from user's library (Last.fm similar artists)
@@ -52,7 +54,7 @@ router.get("/radar", async (req, res) => {
const endDate = new Date(now); const endDate = new Date(now);
endDate.setDate(endDate.getDate() + daysAhead); endDate.setDate(endDate.getDate() + daysAhead);
console.log(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`); logger.debug(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`);
// 1. Get releases from Lidarr calendar (monitored artists) // 1. Get releases from Lidarr calendar (monitored artists)
const lidarrReleases = await lidarrService.getCalendar(startDate, endDate); const lidarrReleases = await lidarrService.getCalendar(startDate, endDate);
@@ -92,8 +94,8 @@ router.get("/radar", async (req, res) => {
sa => sa.toArtist.mbid && !monitoredMbids.has(sa.toArtist.mbid) sa => sa.toArtist.mbid && !monitoredMbids.has(sa.toArtist.mbid)
); );
console.log(`[Releases] Found ${lidarrReleases.length} Lidarr releases`); logger.debug(`[Releases] Found ${lidarrReleases.length} Lidarr releases`);
console.log(`[Releases] Found ${unmonitoredSimilar.length} unmonitored similar artists`); logger.debug(`[Releases] Found ${unmonitoredSimilar.length} unmonitored similar artists`);
// 4. Get albums in library to check what user already has // 4. Get albums in library to check what user already has
const libraryAlbums = await prisma.album.findMany({ const libraryAlbums = await prisma.album.findMany({
@@ -142,7 +144,7 @@ router.get("/radar", async (req, res) => {
res.json(response); res.json(response);
} catch (error: any) { } catch (error: any) {
console.error("[Releases] Radar error:", error.message); logger.error("[Releases] Radar error:", error.message);
res.status(500).json({ error: "Failed to fetch release radar" }); res.status(500).json({ error: "Failed to fetch release radar" });
} }
}); });
@@ -173,7 +175,7 @@ router.get("/upcoming", async (req, res) => {
daysAhead, daysAhead,
}); });
} catch (error: any) { } catch (error: any) {
console.error("[Releases] Upcoming error:", error.message); logger.error("[Releases] Upcoming error:", error.message);
res.status(500).json({ error: "Failed to fetch upcoming releases" }); res.status(500).json({ error: "Failed to fetch upcoming releases" });
} }
}); });
@@ -195,7 +197,6 @@ router.get("/recent", async (req, res) => {
// Get library albums to mark what's already downloaded // Get library albums to mark what's already downloaded
const libraryAlbums = await prisma.album.findMany({ const libraryAlbums = await prisma.album.findMany({
where: { rgMbid: { not: null } },
select: { rgMbid: true } select: { rgMbid: true }
}); });
const libraryMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean)); const libraryMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
@@ -214,7 +215,7 @@ router.get("/recent", async (req, res) => {
inLibraryCount: releases.length - notInLibrary.length, inLibraryCount: releases.length - notInLibrary.length,
}); });
} catch (error: any) { } catch (error: any) {
console.error("[Releases] Recent error:", error.message); logger.error("[Releases] Recent error:", error.message);
res.status(500).json({ error: "Failed to fetch recent releases" }); res.status(500).json({ error: "Failed to fetch recent releases" });
} }
}); });
@@ -233,24 +234,15 @@ router.post("/download/:albumMbid", async (req, res) => {
return res.status(401).json({ error: "Authentication required" }); return res.status(401).json({ error: "Authentication required" });
} }
console.log(`[Releases] Download requested for album: ${albumMbid}`); logger.debug(`[Releases] Download requested for album: ${albumMbid}`);
// Use Lidarr to download the album // TODO: Implement downloadAlbum method on LidarrService
const result = await lidarrService.downloadAlbum(albumMbid); // For now, return not implemented error
res.status(501).json({
if (result) { error: "Download feature not yet implemented for release radar"
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) { } catch (error: any) {
console.error("[Releases] Download error:", error.message); logger.error("[Releases] Download error:", error.message);
res.status(500).json({ error: "Failed to start download" }); res.status(500).json({ error: "Failed to start download" });
} }
}); });
+78 -38
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { audiobookshelfService } from "../services/audiobookshelf"; import { audiobookshelfService } from "../services/audiobookshelf";
@@ -33,7 +34,7 @@ router.use(requireAuth);
* name: type * name: type
* schema: * schema:
* type: string * type: string
* enum: [all, artists, albums, tracks, audiobooks, podcasts] * enum: [all, artists, albums, tracks, audiobooks, podcasts, episodes]
* description: Type of content to search * description: Type of content to search
* default: all * default: all
* - in: query * - in: query
@@ -102,11 +103,13 @@ router.get("/", async (req, res) => {
} }
// Check cache for library search (short TTL since library can change) // Check cache for library search (short TTL since library can change)
const cacheKey = `search:library:${type}:${genre || ""}:${query}:${searchLimit}`; const cacheKey = `search:library:${type}:${
genre || ""
}:${query}:${searchLimit}`;
try { try {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log(`[SEARCH] Cache hit for query="${query}"`); logger.debug(`[SEARCH] Cache hit for query="${query}"`);
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
} catch (err) { } catch (err) {
@@ -119,6 +122,7 @@ router.get("/", async (req, res) => {
tracks: [], tracks: [],
audiobooks: [], audiobooks: [],
podcasts: [], podcasts: [],
episodes: [],
}; };
// Search artists using full-text search (only show artists with actual albums in library) // Search artists using full-text search (only show artists with actual albums in library)
@@ -246,41 +250,48 @@ router.get("/", async (req, res) => {
} }
} }
// Search audiobooks // Search audiobooks using FTS
if (type === "all" || type === "audiobooks") { if (type === "all" || type === "audiobooks") {
try { try {
const audiobooks = await audiobookshelfService.searchAudiobooks( const audiobooks = await searchService.searchAudiobooksFTS({
query query,
); limit: searchLimit,
results.audiobooks = audiobooks.slice(0, searchLimit); });
results.audiobooks = audiobooks;
} catch (error) { } catch (error) {
console.error("Audiobook search error:", error); logger.error("Audiobook search error:", error);
results.audiobooks = []; results.audiobooks = [];
} }
} }
// Search podcasts (search through owned podcasts) // Search podcasts using FTS
if (type === "all" || type === "podcasts") { if (type === "all" || type === "podcasts") {
try { try {
const allPodcasts = const podcasts = await searchService.searchPodcastsFTS({
await audiobookshelfService.getAllPodcasts(); query,
results.podcasts = allPodcasts limit: searchLimit,
.filter( });
(p) => results.podcasts = podcasts;
p.media?.metadata?.title
?.toLowerCase()
.includes(query.toLowerCase()) ||
p.media?.metadata?.author
?.toLowerCase()
.includes(query.toLowerCase())
)
.slice(0, searchLimit);
} catch (error) { } catch (error) {
console.error("Podcast search error:", error); logger.error("Podcast search error:", error);
results.podcasts = []; results.podcasts = [];
} }
} }
// Search podcast episodes
if (type === "all" || type === "episodes") {
try {
const episodes = await searchService.searchEpisodes({
query,
limit: searchLimit,
});
results.episodes = episodes;
} catch (error) {
logger.error("Episode search error:", error);
results.episodes = [];
}
}
// Cache search results for 2 minutes (library can change) // Cache search results for 2 minutes (library can change)
try { try {
await redisClient.setEx(cacheKey, 120, JSON.stringify(results)); await redisClient.setEx(cacheKey, 120, JSON.stringify(results));
@@ -290,7 +301,7 @@ router.get("/", async (req, res) => {
res.json(results); res.json(results);
} catch (error) { } catch (error) {
console.error("Search error:", error); logger.error("Search error:", error);
res.status(500).json({ error: "Search failed" }); res.status(500).json({ error: "Search failed" });
} }
}); });
@@ -315,7 +326,7 @@ router.get("/genres", async (req, res) => {
})) }))
); );
} catch (error) { } catch (error) {
console.error("Get genres error:", error); logger.error("Get genres error:", error);
res.status(500).json({ error: "Failed to get genres" }); res.status(500).json({ error: "Failed to get genres" });
} }
}); });
@@ -339,13 +350,13 @@ router.get("/discover", async (req, res) => {
try { try {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log( logger.debug(
`[SEARCH DISCOVER] Cache hit for query="${query}" type=${type}` `[SEARCH DISCOVER] Cache hit for query="${query}" type=${type}`
); );
return res.json(JSON.parse(cached)); return res.json(JSON.parse(cached));
} }
} catch (err) { } catch (err) {
console.warn("[SEARCH DISCOVER] Redis read error:", err); logger.warn("[SEARCH DISCOVER] Redis read error:", err);
} }
const results: any[] = []; const results: any[] = [];
@@ -353,27 +364,56 @@ router.get("/discover", async (req, res) => {
if (type === "music" || type === "all") { if (type === "music" || type === "all") {
// Search Last.fm for artists AND tracks // Search Last.fm for artists AND tracks
try { try {
// Search for artists // Check if query is a potential alias
let searchQuery = query;
let aliasInfo: any = null;
try {
const correction = await lastFmService.getArtistCorrection(query);
if (correction?.corrected) {
// Query is an alias - search for canonical name instead
searchQuery = correction.canonicalName;
aliasInfo = {
type: "alias_resolution",
original: query,
canonical: correction.canonicalName,
mbid: correction.mbid,
};
logger.debug(
`[SEARCH DISCOVER] Alias resolved: "${query}" → "${correction.canonicalName}"`
);
}
} catch (correctionError) {
logger.warn("[SEARCH DISCOVER] Correction check failed:", correctionError);
}
// Search for artists (using potentially corrected query)
const lastfmArtistResults = await lastFmService.searchArtists( const lastfmArtistResults = await lastFmService.searchArtists(
query, searchQuery,
searchLimit searchLimit
); );
console.log( logger.debug(
`[SEARCH ENDPOINT] Found ${lastfmArtistResults.length} artist results` `[SEARCH ENDPOINT] Found ${lastfmArtistResults.length} artist results`
); );
// Add alias info to response if applicable
if (aliasInfo) {
results.push(aliasInfo);
}
results.push(...lastfmArtistResults); results.push(...lastfmArtistResults);
// Search for tracks (songs) // Search for tracks (songs) - use corrected query for consistency
const lastfmTrackResults = await lastFmService.searchTracks( const lastfmTrackResults = await lastFmService.searchTracks(
query, searchQuery,
searchLimit searchLimit
); );
console.log( logger.debug(
`[SEARCH ENDPOINT] Found ${lastfmTrackResults.length} track results` `[SEARCH ENDPOINT] Found ${lastfmTrackResults.length} track results`
); );
results.push(...lastfmTrackResults); results.push(...lastfmTrackResults);
} catch (error) { } catch (error) {
console.error("Last.fm search error:", error); logger.error("Last.fm search error:", error);
} }
} }
@@ -410,7 +450,7 @@ router.get("/discover", async (req, res) => {
results.push(...podcasts); results.push(...podcasts);
} catch (error) { } catch (error) {
console.error("iTunes podcast search error:", error); logger.error("iTunes podcast search error:", error);
} }
} }
@@ -419,12 +459,12 @@ router.get("/discover", async (req, res) => {
try { try {
await redisClient.setEx(cacheKey, 900, JSON.stringify(payload)); await redisClient.setEx(cacheKey, 900, JSON.stringify(payload));
} catch (err) { } catch (err) {
console.warn("[SEARCH DISCOVER] Redis write error:", err); logger.warn("[SEARCH DISCOVER] Redis write error:", err);
} }
res.json(payload); res.json(payload);
} catch (error) { } catch (error) {
console.error("Discovery search error:", error); logger.error("Discovery search error:", error);
res.status(500).json({ error: "Discovery search failed" }); res.status(500).json({ error: "Discovery search failed" });
} }
}); });
+25 -2
View File
@@ -1,7 +1,9 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth } from "../middleware/auth"; import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod"; import { z } from "zod";
import { staleJobCleanupService } from "../services/staleJobCleanup";
const router = Router(); const router = Router();
@@ -38,7 +40,7 @@ router.get("/", async (req, res) => {
res.json(settings); res.json(settings);
} catch (error) { } catch (error) {
console.error("Get settings error:", error); logger.error("Get settings error:", error);
res.status(500).json({ error: "Failed to get settings" }); res.status(500).json({ error: "Failed to get settings" });
} }
}); });
@@ -65,9 +67,30 @@ router.post("/", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid settings", details: error.errors }); .json({ error: "Invalid settings", details: error.errors });
} }
console.error("Update settings error:", error); logger.error("Update settings error:", error);
res.status(500).json({ error: "Failed to update settings" }); res.status(500).json({ error: "Failed to update settings" });
} }
}); });
// POST /settings/cleanup-stale-jobs
router.post("/cleanup-stale-jobs", async (req, res) => {
try {
const result = await staleJobCleanupService.cleanupAll();
res.json({
success: true,
cleaned: {
discoveryBatches: result.discoveryBatches,
downloadJobs: result.downloadJobs,
spotifyImportJobs: result.spotifyImportJobs,
bullQueues: result.bullQueues,
},
totalCleaned: result.totalCleaned,
});
} catch (error) {
logger.error("Stale job cleanup error:", error);
res.status(500).json({ error: "Failed to cleanup stale jobs" });
}
});
export default router; export default router;
+9 -7
View File
@@ -1,3 +1,5 @@
import { logger } from "../utils/logger";
/** /**
* Soulseek routes - Direct connection via slsk-client * Soulseek routes - Direct connection via slsk-client
* Simplified API for status and manual search/download * Simplified API for status and manual search/download
@@ -23,7 +25,7 @@ async function requireSoulseekConfigured(req: any, res: any, next: any) {
next(); next();
} catch (error) { } catch (error) {
console.error("Error checking Soulseek settings:", error); logger.error("Error checking Soulseek settings:", error);
res.status(500).json({ error: "Failed to check settings" }); res.status(500).json({ error: "Failed to check settings" });
} }
} }
@@ -52,7 +54,7 @@ router.get("/status", requireAuth, async (req, res) => {
username: status.username, username: status.username,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Soulseek status error:", error.message); logger.error("Soulseek status error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Failed to get Soulseek status", error: "Failed to get Soulseek status",
details: error.message, details: error.message,
@@ -73,7 +75,7 @@ router.post("/connect", requireAuth, requireSoulseekConfigured, async (req, res)
message: "Connected to Soulseek network", message: "Connected to Soulseek network",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Soulseek connect error:", error.message); logger.error("Soulseek connect error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Failed to connect to Soulseek", error: "Failed to connect to Soulseek",
details: error.message, details: error.message,
@@ -95,7 +97,7 @@ router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res)
}); });
} }
console.log(`[Soulseek] Searching: "${artist} - ${title}"`); logger.debug(`[Soulseek] Searching: "${artist} - ${title}"`);
const result = await soulseekService.searchTrack(artist, title); const result = await soulseekService.searchTrack(artist, title);
@@ -117,7 +119,7 @@ router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res)
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error("Soulseek search error:", error.message); logger.error("Soulseek search error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Search failed", error: "Search failed",
details: error.message, details: error.message,
@@ -148,7 +150,7 @@ router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res
}); });
} }
console.log(`[Soulseek] Downloading: "${artist} - ${title}"`); logger.debug(`[Soulseek] Downloading: "${artist} - ${title}"`);
const result = await soulseekService.searchAndDownload( const result = await soulseekService.searchAndDownload(
artist, artist,
@@ -169,7 +171,7 @@ router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error("Soulseek download error:", error.message); logger.error("Soulseek download error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Download failed", error: "Download failed",
details: error.message, details: error.message,
+25 -14
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuthOrToken } from "../middleware/auth"; import { requireAuthOrToken } from "../middleware/auth";
import { z } from "zod"; import { z } from "zod";
import { spotifyService } from "../services/spotify"; import { spotifyService } from "../services/spotify";
@@ -51,7 +52,7 @@ router.post("/parse", async (req, res) => {
url: `https://open.spotify.com/playlist/${parsed.id}`, url: `https://open.spotify.com/playlist/${parsed.id}`,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Spotify parse error:", error); logger.error("Spotify parse error:", error);
if (error.name === "ZodError") { if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" }); return res.status(400).json({ error: "Invalid request body" });
} }
@@ -67,7 +68,7 @@ router.post("/preview", async (req, res) => {
try { try {
const { url } = parseUrlSchema.parse(req.body); const { url } = parseUrlSchema.parse(req.body);
console.log(`[Playlist Import] Generating preview for: ${url}`); logger.debug(`[Playlist Import] Generating preview for: ${url}`);
// Detect if it's a Deezer URL // Detect if it's a Deezer URL
if (url.includes("deezer.com")) { if (url.includes("deezer.com")) {
@@ -94,7 +95,7 @@ router.post("/preview", async (req, res) => {
deezerPlaylist deezerPlaylist
); );
console.log( logger.debug(
`[Playlist Import] Deezer preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library` `[Playlist Import] Deezer preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
); );
res.json(preview); res.json(preview);
@@ -102,13 +103,13 @@ router.post("/preview", async (req, res) => {
// Handle Spotify URL // Handle Spotify URL
const preview = await spotifyImportService.generatePreview(url); const preview = await spotifyImportService.generatePreview(url);
console.log( logger.debug(
`[Spotify Import] Preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library` `[Spotify Import] Preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
); );
res.json(preview); res.json(preview);
} }
} catch (error: any) { } catch (error: any) {
console.error("Playlist preview error:", error); logger.error("Playlist preview error:", error);
if (error.name === "ZodError") { if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" }); return res.status(400).json({ error: "Invalid request body" });
} }
@@ -124,6 +125,9 @@ router.post("/preview", async (req, res) => {
*/ */
router.post("/import", async (req, res) => { router.post("/import", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { spotifyPlaylistId, url, playlistName, albumMbidsToDownload } = const { spotifyPlaylistId, url, playlistName, albumMbidsToDownload } =
importSchema.parse(req.body); importSchema.parse(req.body);
const userId = req.user.id; const userId = req.user.id;
@@ -155,10 +159,10 @@ router.post("/import", async (req, res) => {
preview = await spotifyImportService.generatePreview(effectiveUrl); preview = await spotifyImportService.generatePreview(effectiveUrl);
} }
console.log( logger.debug(
`[Spotify Import] Starting import for user ${userId}: ${playlistName}` `[Spotify Import] Starting import for user ${userId}: ${playlistName}`
); );
console.log( logger.debug(
`[Spotify Import] Downloading ${albumMbidsToDownload.length} albums` `[Spotify Import] Downloading ${albumMbidsToDownload.length} albums`
); );
@@ -176,7 +180,7 @@ router.post("/import", async (req, res) => {
message: "Import started", message: "Import started",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Spotify import error:", error); logger.error("Spotify import error:", error);
if (error.name === "ZodError") { if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" }); return res.status(400).json({ error: "Invalid request body" });
} }
@@ -192,6 +196,9 @@ router.post("/import", async (req, res) => {
*/ */
router.get("/import/:jobId/status", async (req, res) => { router.get("/import/:jobId/status", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const { jobId } = req.params; const { jobId } = req.params;
const userId = req.user.id; const userId = req.user.id;
@@ -209,7 +216,7 @@ router.get("/import/:jobId/status", async (req, res) => {
res.json(job); res.json(job);
} catch (error: any) { } catch (error: any) {
console.error("Spotify job status error:", error); logger.error("Spotify job status error:", error);
res.status(500).json({ res.status(500).json({
error: error.message || "Failed to get job status", error: error.message || "Failed to get job status",
}); });
@@ -222,11 +229,14 @@ router.get("/import/:jobId/status", async (req, res) => {
*/ */
router.get("/imports", async (req, res) => { router.get("/imports", async (req, res) => {
try { try {
if (!req.user) {
return res.status(401).json({ error: "Unauthorized" });
}
const userId = req.user.id; const userId = req.user.id;
const jobs = await spotifyImportService.getUserJobs(userId); const jobs = await spotifyImportService.getUserJobs(userId);
res.json(jobs); res.json(jobs);
} catch (error: any) { } catch (error: any) {
console.error("Spotify imports error:", error); logger.error("Spotify imports error:", error);
res.status(500).json({ res.status(500).json({
error: error.message || "Failed to get imports", error: error.message || "Failed to get imports",
}); });
@@ -240,6 +250,7 @@ router.get("/imports", async (req, res) => {
router.post("/import/:jobId/refresh", async (req, res) => { router.post("/import/:jobId/refresh", async (req, res) => {
try { try {
const { jobId } = req.params; const { jobId } = req.params;
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
const userId = req.user.id; const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId); const job = await spotifyImportService.getJob(jobId);
@@ -265,7 +276,7 @@ router.post("/import/:jobId/refresh", async (req, res) => {
total: result.total, total: result.total,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Spotify refresh error:", error); logger.error("Spotify refresh error:", error);
res.status(500).json({ res.status(500).json({
error: error.message || "Failed to refresh tracks", error: error.message || "Failed to refresh tracks",
}); });
@@ -279,7 +290,7 @@ router.post("/import/:jobId/refresh", async (req, res) => {
router.post("/import/:jobId/cancel", async (req, res) => { router.post("/import/:jobId/cancel", async (req, res) => {
try { try {
const { jobId } = req.params; const { jobId } = req.params;
const userId = req.user.id; const userId = req.user!.id;
const job = await spotifyImportService.getJob(jobId); const job = await spotifyImportService.getJob(jobId);
if (!job) { if (!job) {
@@ -303,7 +314,7 @@ router.post("/import/:jobId/cancel", async (req, res) => {
tracksMatched: result.tracksMatched, tracksMatched: result.tracksMatched,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Spotify cancel error:", error); logger.error("Spotify cancel error:", error);
res.status(500).json({ res.status(500).json({
error: error.message || "Failed to cancel import", error: error.message || "Failed to cancel import",
}); });
@@ -324,7 +335,7 @@ router.get("/import/session-log", async (req, res) => {
content: log, content: log,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Session log error:", error); logger.error("Session log error:", error);
res.status(500).json({ res.status(500).json({
error: error.message || "Failed to read session log", error: error.message || "Failed to read session log",
}); });
+140 -51
View File
@@ -1,4 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import { logger } from "../utils/logger";
import { requireAuth, requireAdmin } from "../middleware/auth"; import { requireAuth, requireAdmin } from "../middleware/auth";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { z } from "zod"; import { z } from "zod";
@@ -17,7 +18,7 @@ function safeDecrypt(value: string | null): string | null {
try { try {
return decrypt(value); return decrypt(value);
} catch (error) { } catch (error) {
console.warn("[Settings Route] Failed to decrypt field, returning null"); logger.warn("[Settings Route] Failed to decrypt field, returning null");
return null; return null;
} }
} }
@@ -31,6 +32,7 @@ const systemSettingsSchema = z.object({
lidarrEnabled: z.boolean().optional(), lidarrEnabled: z.boolean().optional(),
lidarrUrl: z.string().optional(), lidarrUrl: z.string().optional(),
lidarrApiKey: z.string().nullable().optional(), lidarrApiKey: z.string().nullable().optional(),
lidarrWebhookSecret: z.string().nullable().optional(),
// AI Services // AI Services
openaiEnabled: z.boolean().optional(), openaiEnabled: z.boolean().optional(),
@@ -41,6 +43,8 @@ const systemSettingsSchema = z.object({
fanartEnabled: z.boolean().optional(), fanartEnabled: z.boolean().optional(),
fanartApiKey: z.string().nullable().optional(), fanartApiKey: z.string().nullable().optional(),
lastfmApiKey: z.string().nullable().optional(),
// Media Services // Media Services
audiobookshelfEnabled: z.boolean().optional(), audiobookshelfEnabled: z.boolean().optional(),
audiobookshelfUrl: z.string().optional(), audiobookshelfUrl: z.string().optional(),
@@ -66,10 +70,11 @@ const systemSettingsSchema = z.object({
maxConcurrentDownloads: z.number().optional(), maxConcurrentDownloads: z.number().optional(),
downloadRetryAttempts: z.number().optional(), downloadRetryAttempts: z.number().optional(),
transcodeCacheMaxGb: z.number().optional(), transcodeCacheMaxGb: z.number().optional(),
soulseekConcurrentDownloads: z.number().min(1).max(10).optional(),
// Download Preferences // Download Preferences
downloadSource: z.enum(["soulseek", "lidarr"]).optional(), downloadSource: z.enum(["soulseek", "lidarr"]).optional(),
soulseekFallback: z.enum(["none", "lidarr"]).optional(), primaryFailureFallback: z.enum(["none", "lidarr", "soulseek"]).optional(),
}); });
// GET /system-settings // GET /system-settings
@@ -107,8 +112,10 @@ router.get("/", async (req, res) => {
const decryptedSettings = { const decryptedSettings = {
...settings, ...settings,
lidarrApiKey: safeDecrypt(settings.lidarrApiKey), lidarrApiKey: safeDecrypt(settings.lidarrApiKey),
lidarrWebhookSecret: safeDecrypt(settings.lidarrWebhookSecret),
openaiApiKey: safeDecrypt(settings.openaiApiKey), openaiApiKey: safeDecrypt(settings.openaiApiKey),
fanartApiKey: safeDecrypt(settings.fanartApiKey), fanartApiKey: safeDecrypt(settings.fanartApiKey),
lastfmApiKey: safeDecrypt(settings.lastfmApiKey),
audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey), audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey),
soulseekPassword: safeDecrypt(settings.soulseekPassword), soulseekPassword: safeDecrypt(settings.soulseekPassword),
spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret), spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret),
@@ -116,7 +123,7 @@ router.get("/", async (req, res) => {
res.json(decryptedSettings); res.json(decryptedSettings);
} catch (error) { } catch (error) {
console.error("Get system settings error:", error); logger.error("Get system settings error:", error);
res.status(500).json({ error: "Failed to get system settings" }); res.status(500).json({ error: "Failed to get system settings" });
} }
}); });
@@ -126,8 +133,8 @@ router.post("/", async (req, res) => {
try { try {
const data = systemSettingsSchema.parse(req.body); const data = systemSettingsSchema.parse(req.body);
console.log("[SYSTEM SETTINGS] Saving settings..."); logger.debug("[SYSTEM SETTINGS] Saving settings...");
console.log( logger.debug(
"[SYSTEM SETTINGS] transcodeCacheMaxGb:", "[SYSTEM SETTINGS] transcodeCacheMaxGb:",
data.transcodeCacheMaxGb data.transcodeCacheMaxGb
); );
@@ -137,10 +144,14 @@ router.post("/", async (req, res) => {
if (data.lidarrApiKey) if (data.lidarrApiKey)
encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey); encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey);
if (data.lidarrWebhookSecret)
encryptedData.lidarrWebhookSecret = encrypt(data.lidarrWebhookSecret);
if (data.openaiApiKey) if (data.openaiApiKey)
encryptedData.openaiApiKey = encrypt(data.openaiApiKey); encryptedData.openaiApiKey = encrypt(data.openaiApiKey);
if (data.fanartApiKey) if (data.fanartApiKey)
encryptedData.fanartApiKey = encrypt(data.fanartApiKey); encryptedData.fanartApiKey = encrypt(data.fanartApiKey);
if (data.lastfmApiKey)
encryptedData.lastfmApiKey = encrypt(data.lastfmApiKey);
if (data.audiobookshelfApiKey) if (data.audiobookshelfApiKey)
encryptedData.audiobookshelfApiKey = encrypt( encryptedData.audiobookshelfApiKey = encrypt(
data.audiobookshelfApiKey data.audiobookshelfApiKey
@@ -161,19 +172,27 @@ router.post("/", async (req, res) => {
invalidateSystemSettingsCache(); invalidateSystemSettingsCache();
// Refresh Last.fm API key if it was updated
try {
const { lastFmService } = await import("../services/lastfm");
await lastFmService.refreshApiKey();
} catch (err) {
logger.warn("Failed to refresh Last.fm API key:", err);
}
// If Audiobookshelf was disabled, clear all audiobook-related data // If Audiobookshelf was disabled, clear all audiobook-related data
if (data.audiobookshelfEnabled === false) { if (data.audiobookshelfEnabled === false) {
console.log( logger.debug(
"[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database" "[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database"
); );
try { try {
const deletedProgress = const deletedProgress =
await prisma.audiobookProgress.deleteMany({}); await prisma.audiobookProgress.deleteMany({});
console.log( logger.debug(
` Deleted ${deletedProgress.count} audiobook progress entries` ` Deleted ${deletedProgress.count} audiobook progress entries`
); );
} catch (clearError) { } catch (clearError) {
console.error("Failed to clear audiobook data:", clearError); logger.error("Failed to clear audiobook data:", clearError);
// Don't fail the request // Don't fail the request
} }
} }
@@ -191,28 +210,28 @@ router.post("/", async (req, res) => {
SOULSEEK_USERNAME: data.soulseekUsername || null, SOULSEEK_USERNAME: data.soulseekUsername || null,
SOULSEEK_PASSWORD: data.soulseekPassword || null, SOULSEEK_PASSWORD: data.soulseekPassword || null,
}); });
console.log(".env file synchronized with database settings"); logger.debug(".env file synchronized with database settings");
} catch (envError) { } catch (envError) {
console.error("Failed to write .env file:", envError); logger.error("Failed to write .env file:", envError);
// Don't fail the request if .env write fails // Don't fail the request if .env write fails
} }
// Auto-configure Lidarr webhook if Lidarr is enabled // Auto-configure Lidarr webhook if Lidarr is enabled
if (data.lidarrEnabled && data.lidarrUrl && data.lidarrApiKey) { if (data.lidarrEnabled && data.lidarrUrl && data.lidarrApiKey) {
try { try {
console.log("[LIDARR] Auto-configuring webhook..."); logger.debug("[LIDARR] Auto-configuring webhook...");
const axios = (await import("axios")).default; const axios = (await import("axios")).default;
const lidarrUrl = data.lidarrUrl; const lidarrUrl = data.lidarrUrl;
const apiKey = data.lidarrApiKey; const apiKey = data.lidarrApiKey;
// Determine webhook URL // Determine webhook URL
// Use LIDIFY_CALLBACK_URL env var if set, otherwise default to host.docker.internal:3030 // Use LIDIFY_CALLBACK_URL env var if set, otherwise default to backend:3006
// Port 3030 is the external Nginx port that Lidarr can reach // In Docker, services communicate via Docker network names (backend, lidarr, etc.)
const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://host.docker.internal:3030"; const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://backend:3006";
const webhookUrl = `${callbackHost}/api/webhooks/lidarr`; const webhookUrl = `${callbackHost}/api/webhooks/lidarr`;
console.log(` Webhook URL: ${webhookUrl}`); logger.debug(` Webhook URL: ${webhookUrl}`);
// Check if webhook already exists - find by name "Lidify" OR by URL containing "lidify" or "webhooks/lidarr" // Check if webhook already exists - find by name "Lidify" OR by URL containing "lidify" or "webhooks/lidarr"
const notificationsResponse = await axios.get( const notificationsResponse = await axios.get(
@@ -241,10 +260,10 @@ router.post("/", async (req, res) => {
if (existingWebhook) { if (existingWebhook) {
const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value; const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value;
console.log(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`); logger.debug(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`);
if (currentUrl !== webhookUrl) { if (currentUrl !== webhookUrl) {
console.log(` URL needs updating from: ${currentUrl}`); logger.debug(` URL needs updating from: ${currentUrl}`);
console.log(` URL will be updated to: ${webhookUrl}`); logger.debug(` URL will be updated to: ${webhookUrl}`);
} }
} }
@@ -293,7 +312,7 @@ router.post("/", async (req, res) => {
timeout: 10000, timeout: 10000,
} }
); );
console.log(" Webhook updated"); logger.debug(" Webhook updated");
} else { } else {
// Create new webhook (use forceSave to skip test) // Create new webhook (use forceSave to skip test)
await axios.post( await axios.post(
@@ -304,22 +323,22 @@ router.post("/", async (req, res) => {
timeout: 10000, timeout: 10000,
} }
); );
console.log(" Webhook created"); logger.debug(" Webhook created");
} }
console.log("Lidarr webhook configured automatically\n"); logger.debug("Lidarr webhook configured automatically\n");
} catch (webhookError: any) { } catch (webhookError: any) {
console.error( logger.error(
"Failed to auto-configure webhook:", "Failed to auto-configure webhook:",
webhookError.message webhookError.message
); );
if (webhookError.response?.data) { if (webhookError.response?.data) {
console.error( logger.error(
" Lidarr error details:", " Lidarr error details:",
JSON.stringify(webhookError.response.data, null, 2) JSON.stringify(webhookError.response.data, null, 2)
); );
} }
console.log( logger.debug(
" User can configure webhook manually in Lidarr UI\n" " User can configure webhook manually in Lidarr UI\n"
); );
// Don't fail the request if webhook config fails // Don't fail the request if webhook config fails
@@ -338,7 +357,7 @@ router.post("/", async (req, res) => {
.status(400) .status(400)
.json({ error: "Invalid settings", details: error.errors }); .json({ error: "Invalid settings", details: error.errors });
} }
console.error("Update system settings error:", error); logger.error("Update system settings error:", error);
res.status(500).json({ error: "Failed to update system settings" }); res.status(500).json({ error: "Failed to update system settings" });
} }
}); });
@@ -348,7 +367,7 @@ router.post("/test-lidarr", async (req, res) => {
try { try {
const { url, apiKey } = req.body; const { url, apiKey } = req.body;
console.log("[Lidarr Test] Testing connection to:", url); logger.debug("[Lidarr Test] Testing connection to:", url);
if (!url || !apiKey) { if (!url || !apiKey) {
return res return res
@@ -368,7 +387,7 @@ router.post("/test-lidarr", async (req, res) => {
} }
); );
console.log( logger.debug(
"[Lidarr Test] Connection successful, version:", "[Lidarr Test] Connection successful, version:",
response.data.version response.data.version
); );
@@ -379,8 +398,8 @@ router.post("/test-lidarr", async (req, res) => {
version: response.data.version, version: response.data.version,
}); });
} catch (error: any) { } catch (error: any) {
console.error("[Lidarr Test] Error:", error.message); logger.error("[Lidarr Test] Error:", error.message);
console.error( logger.error(
"[Lidarr Test] Details:", "[Lidarr Test] Details:",
error.response?.data || error.code error.response?.data || error.code
); );
@@ -433,7 +452,7 @@ router.post("/test-openai", async (req, res) => {
model: response.data.model, model: response.data.model,
}); });
} catch (error: any) { } catch (error: any) {
console.error("OpenAI test error:", error.message); logger.error("OpenAI test error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Failed to connect to OpenAI", error: "Failed to connect to OpenAI",
details: error.response?.data?.error?.message || error.message, details: error.response?.data?.error?.message || error.message,
@@ -469,7 +488,7 @@ router.post("/test-fanart", async (req, res) => {
message: "Fanart.tv connection successful", message: "Fanart.tv connection successful",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Fanart.tv test error:", error.message); logger.error("Fanart.tv test error:", error.message);
if (error.response?.status === 401) { if (error.response?.status === 401) {
res.status(401).json({ res.status(401).json({
error: "Invalid Fanart.tv API key", error: "Invalid Fanart.tv API key",
@@ -483,6 +502,59 @@ router.post("/test-fanart", async (req, res) => {
} }
}); });
// Test Last.fm connection
router.post("/test-lastfm", async (req, res) => {
try {
const { lastfmApiKey } = req.body;
if (!lastfmApiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
// Test with a known artist (The Beatles)
const testArtist = "The Beatles";
const response = await axios.get(
"http://ws.audioscrobbler.com/2.0/",
{
params: {
method: "artist.getinfo",
artist: testArtist,
api_key: lastfmApiKey,
format: "json",
},
timeout: 5000,
}
);
// If we get here and have artist data, the API key is valid
if (response.data.artist) {
res.json({
success: true,
message: "Last.fm connection successful",
});
} else {
res.status(500).json({
error: "Unexpected response from Last.fm",
});
}
} catch (error: any) {
logger.error("Last.fm test error:", error.message);
if (error.response?.status === 403 || error.response?.data?.error === 10) {
res.status(401).json({
error: "Invalid Last.fm API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Last.fm",
details: error.response?.data || error.message,
});
}
}
});
// Test Audiobookshelf connection // Test Audiobookshelf connection
router.post("/test-audiobookshelf", async (req, res) => { router.post("/test-audiobookshelf", async (req, res) => {
try { try {
@@ -509,7 +581,7 @@ router.post("/test-audiobookshelf", async (req, res) => {
libraries: response.data.libraries?.length || 0, libraries: response.data.libraries?.length || 0,
}); });
} catch (error: any) { } catch (error: any) {
console.error("Audiobookshelf test error:", error.message); logger.error("Audiobookshelf test error:", error.message);
if (error.response?.status === 401 || error.response?.status === 403) { if (error.response?.status === 401 || error.response?.status === 403) {
res.status(401).json({ res.status(401).json({
error: "Invalid Audiobookshelf API key", error: "Invalid Audiobookshelf API key",
@@ -534,7 +606,7 @@ router.post("/test-soulseek", async (req, res) => {
}); });
} }
console.log(`[SOULSEEK-TEST] Testing connection as "${username}"...`); logger.debug(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
// Import soulseek service // Import soulseek service
const { soulseekService } = await import("../services/soulseek"); const { soulseekService } = await import("../services/soulseek");
@@ -550,10 +622,10 @@ router.post("/test-soulseek", async (req, res) => {
{ user: username, pass: password }, { user: username, pass: password },
(err: Error | null, client: any) => { (err: Error | null, client: any) => {
if (err) { if (err) {
console.log(`[SOULSEEK-TEST] Connection failed: ${err.message}`); logger.debug(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
return reject(err); return reject(err);
} }
console.log(`[SOULSEEK-TEST] Connected successfully`); logger.debug(`[SOULSEEK-TEST] Connected successfully`);
// We don't need to keep the connection open for the test // We don't need to keep the connection open for the test
resolve(); resolve();
} }
@@ -567,14 +639,14 @@ router.post("/test-soulseek", async (req, res) => {
isConnected: true, isConnected: true,
}); });
} catch (connectError: any) { } catch (connectError: any) {
console.error(`[SOULSEEK-TEST] Error: ${connectError.message}`); logger.error(`[SOULSEEK-TEST] Error: ${connectError.message}`);
res.status(401).json({ res.status(401).json({
error: "Invalid Soulseek credentials or connection failed", error: "Invalid Soulseek credentials or connection failed",
details: connectError.message, details: connectError.message,
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error("[SOULSEEK-TEST] Error:", error.message); logger.error("[SOULSEEK-TEST] Error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Failed to test Soulseek connection", error: "Failed to test Soulseek connection",
details: error.message, details: error.message,
@@ -593,22 +665,39 @@ router.post("/test-spotify", async (req, res) => {
}); });
} }
// Import spotifyService to test credentials // Test credentials by trying to get an access token
const { spotifyService } = await import("../services/spotify"); const axios = require("axios");
const result = await spotifyService.testCredentials(clientId, clientSecret); try {
const response = await axios.post(
"https://accounts.spotify.com/api/token",
"grant_type=client_credentials",
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
},
timeout: 10000,
}
);
if (result.success) { if (response.data.access_token) {
res.json({ res.json({
success: true, success: true,
message: "Spotify credentials are valid", message: "Spotify credentials are valid",
}); });
} else { } else {
res.status(401).json({
error: "Invalid Spotify credentials",
});
}
} catch (tokenError: any) {
res.status(401).json({ res.status(401).json({
error: result.error || "Invalid Spotify credentials", error: "Invalid Spotify credentials",
details: tokenError.response?.data?.error_description || tokenError.message,
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error("Spotify test error:", error.message); logger.error("Spotify test error:", error.message);
res.status(500).json({ res.status(500).json({
error: "Failed to test Spotify credentials", error: "Failed to test Spotify credentials",
details: error.message, details: error.message,
@@ -661,7 +750,7 @@ router.post("/clear-caches", async (req, res) => {
); );
if (keysToDelete.length > 0) { if (keysToDelete.length > 0) {
console.log( logger.debug(
`[CACHE] Clearing ${ `[CACHE] Clearing ${
keysToDelete.length keysToDelete.length
} cache entries (excluding ${ } cache entries (excluding ${
@@ -671,7 +760,7 @@ router.post("/clear-caches", async (req, res) => {
for (const key of keysToDelete) { for (const key of keysToDelete) {
await redisClient.del(key); await redisClient.del(key);
} }
console.log( logger.debug(
`[CACHE] Successfully cleared ${keysToDelete.length} cache entries` `[CACHE] Successfully cleared ${keysToDelete.length} cache entries`
); );
@@ -701,7 +790,7 @@ router.post("/clear-caches", async (req, res) => {
}); });
} }
} catch (error: any) { } catch (error: any) {
console.error("Clear caches error:", error); logger.error("Clear caches error:", error);
res.status(500).json({ res.status(500).json({
error: "Failed to clear caches", error: "Failed to clear caches",
details: error.message, details: error.message,
+68 -68
View File
@@ -6,15 +6,26 @@
*/ */
import { Router } from "express"; import { Router } from "express";
import { prisma } from "../utils/db";
import { scanQueue } from "../workers/queues"; import { scanQueue } from "../workers/queues";
import { discoverWeeklyService } from "../services/discoverWeekly";
import { simpleDownloadManager } from "../services/simpleDownloadManager"; import { simpleDownloadManager } from "../services/simpleDownloadManager";
import { queueCleaner } from "../jobs/queueCleaner"; import { queueCleaner } from "../jobs/queueCleaner";
import { getSystemSettings } from "../utils/systemSettings"; import { getSystemSettings } from "../utils/systemSettings";
import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
const router = Router(); const router = Router();
// GET /webhooks/lidarr/verify - Webhook verification endpoint
router.get("/lidarr/verify", (req, res) => {
logger.debug("[WEBHOOK] Verification request received");
res.json({
status: "ok",
timestamp: new Date().toISOString(),
service: "lidify",
version: process.env.npm_package_version || "unknown",
});
});
// POST /webhooks/lidarr - Handle Lidarr webhooks // POST /webhooks/lidarr - Handle Lidarr webhooks
router.post("/lidarr", async (req, res) => { router.post("/lidarr", async (req, res) => {
try { try {
@@ -25,7 +36,7 @@ router.post("/lidarr", async (req, res) => {
!settings?.lidarrUrl || !settings?.lidarrUrl ||
!settings?.lidarrApiKey !settings?.lidarrApiKey
) { ) {
console.log( logger.debug(
`[WEBHOOK] Lidarr webhook received but Lidarr is disabled. Ignoring.` `[WEBHOOK] Lidarr webhook received but Lidarr is disabled. Ignoring.`
); );
return res.status(202).json({ return res.status(202).json({
@@ -35,12 +46,27 @@ router.post("/lidarr", async (req, res) => {
}); });
} }
// Verify webhook secret if configured
// Note: settings.lidarrWebhookSecret is already decrypted by getSystemSettings()
if (settings.lidarrWebhookSecret) {
const providedSecret = req.headers["x-webhook-secret"] as string;
if (!providedSecret || providedSecret !== settings.lidarrWebhookSecret) {
logger.debug(
`[WEBHOOK] Lidarr webhook received with invalid or missing secret`
);
return res.status(401).json({
error: "Unauthorized - Invalid webhook secret",
});
}
}
const eventType = req.body.eventType; const eventType = req.body.eventType;
console.log(`[WEBHOOK] Lidarr event: ${eventType}`); logger.debug(`[WEBHOOK] Lidarr event: ${eventType}`);
// Log payload in debug mode only (avoid verbose logs in production) // Log payload in debug mode only (avoid verbose logs in production)
if (process.env.DEBUG_WEBHOOKS === "true") { if (process.env.DEBUG_WEBHOOKS === "true") {
console.log(` Payload:`, JSON.stringify(req.body, null, 2)); logger.debug(` Payload:`, JSON.stringify(req.body, null, 2));
} }
switch (eventType) { switch (eventType) {
@@ -68,16 +94,16 @@ router.post("/lidarr", async (req, res) => {
break; break;
case "Test": case "Test":
console.log(" Lidarr test webhook received"); logger.debug(" Lidarr test webhook received");
break; break;
default: default:
console.log(` Unhandled event: ${eventType}`); logger.debug(` Unhandled event: ${eventType}`);
} }
res.json({ success: true }); res.json({ success: true });
} catch (error: any) { } catch (error: any) {
console.error("Webhook error:", error.message); logger.error("Webhook error:", error.message);
res.status(500).json({ error: "Webhook processing failed" }); res.status(500).json({ error: "Webhook processing failed" });
} }
}); });
@@ -93,12 +119,12 @@ async function handleGrab(payload: any) {
const artistName = payload.artist?.name; const artistName = payload.artist?.name;
const lidarrAlbumId = payload.albums?.[0]?.id; const lidarrAlbumId = payload.albums?.[0]?.id;
console.log(` Album: ${artistName} - ${albumTitle}`); logger.debug(` Album: ${artistName} - ${albumTitle}`);
console.log(` Download ID: ${downloadId}`); logger.debug(` Download ID: ${downloadId}`);
console.log(` MBID: ${albumMbid}`); logger.debug(` MBID: ${albumMbid}`);
if (!downloadId) { if (!downloadId) {
console.log(` Missing downloadId, skipping`); logger.debug(` Missing downloadId, skipping`);
return; return;
} }
@@ -128,13 +154,13 @@ async function handleDownload(payload: any) {
payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId; payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
const lidarrAlbumId = payload.album?.id || payload.albums?.[0]?.id; const lidarrAlbumId = payload.album?.id || payload.albums?.[0]?.id;
console.log(` Album: ${artistName} - ${albumTitle}`); logger.debug(` Album: ${artistName} - ${albumTitle}`);
console.log(` Download ID: ${downloadId}`); logger.debug(` Download ID: ${downloadId}`);
console.log(` Album MBID: ${albumMbid}`); logger.debug(` Album MBID: ${albumMbid}`);
console.log(` Lidarr Album ID: ${lidarrAlbumId}`); logger.debug(` Lidarr Album ID: ${lidarrAlbumId}`);
if (!downloadId) { if (!downloadId) {
console.log(` Missing downloadId, skipping`); logger.debug(` Missing downloadId, skipping`);
return; return;
} }
@@ -148,36 +174,30 @@ async function handleDownload(payload: any) {
); );
if (result.jobId) { if (result.jobId) {
// Check if this is part of a download batch (artist download) // Find the download job that triggered this webhook to get userId
if (result.downloadBatchId) { const downloadJob = await prisma.downloadJob.findUnique({
// Check if all jobs in the batch are complete where: { id: result.jobId },
const batchComplete = await checkDownloadBatchComplete( select: { userId: true, id: true },
result.downloadBatchId });
);
if (batchComplete) { // Trigger scan immediately for this album (incremental scan with enrichment data)
console.log( // Don't wait for batch completion - enrichment should happen per-album
` All albums in batch complete, triggering library scan...` logger.debug(
); ` Triggering incremental scan for: ${artistName} - ${albumTitle}`
await scanQueue.add("scan", { );
type: "full", await scanQueue.add("scan", {
source: "lidarr-import-batch", userId: downloadJob?.userId || null,
}); source: "lidarr-webhook",
} else { artistName: artistName,
console.log(` Batch not complete, skipping scan`); albumMbid: albumMbid,
} downloadId: result.jobId,
} else if (!result.batchId) { });
// Single album download (not part of discovery batch)
console.log(` Triggering library scan...`); // Discovery batch completion (for playlist building) is handled by download manager
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import",
});
}
// If part of discovery batch, the download manager already called checkBatchCompletion
} else { } else {
// No job found - this might be an external download not initiated by us // No job found - this might be an external download not initiated by us
// Still trigger a scan to pick up the new music // Still trigger a scan to pick up the new music
console.log(` No matching job, triggering scan anyway...`); logger.debug(` No matching job, triggering scan anyway...`);
await scanQueue.add("scan", { await scanQueue.add("scan", {
type: "full", type: "full",
source: "lidarr-import-external", source: "lidarr-import-external",
@@ -185,26 +205,6 @@ async function handleDownload(payload: any) {
} }
} }
/**
* 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 * Handle import failure with automatic retry
*/ */
@@ -215,12 +215,12 @@ async function handleImportFailure(payload: any) {
const albumTitle = payload.album?.title || payload.release?.title; const albumTitle = payload.album?.title || payload.release?.title;
const reason = payload.message || "Import failed"; const reason = payload.message || "Import failed";
console.log(` Album: ${albumTitle}`); logger.debug(` Album: ${albumTitle}`);
console.log(` Download ID: ${downloadId}`); logger.debug(` Download ID: ${downloadId}`);
console.log(` Reason: ${reason}`); logger.debug(` Reason: ${reason}`);
if (!downloadId) { if (!downloadId) {
console.log(` Missing downloadId, skipping`); logger.debug(` Missing downloadId, skipping`);
return; return;
} }
+850
View File
@@ -0,0 +1,850 @@
/**
* Unified Acquisition Service
*
* Consolidates album/track acquisition logic from Discovery Weekly and Playlist Import.
* Handles download source selection, behavior matrix routing, and job tracking.
*
* Phase 2.1: Initial implementation
* - Behavior matrix logic for primary/fallback source selection
* - Soulseek album acquisition (track list batch download)
* - Lidarr album acquisition (webhook-based completion)
* - DownloadJob management with context-based tracking
*/
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
import { getSystemSettings } from "../utils/systemSettings";
import { soulseekService } from "./soulseek";
import { simpleDownloadManager } from "./simpleDownloadManager";
import { musicBrainzService } from "./musicbrainz";
import { lastFmService } from "./lastfm";
import { AcquisitionError, AcquisitionErrorType } from "./lidarr";
import PQueue from "p-queue";
// ============================================
// TYPE DEFINITIONS
// ============================================
/**
* Context for tracking acquisition origin
* Used to link download jobs to their source (Discovery batch or Spotify import)
*/
export interface AcquisitionContext {
userId: string;
discoveryBatchId?: string;
spotifyImportJobId?: string;
existingJobId?: string;
}
/**
* Request to acquire an album
*/
export interface AlbumAcquisitionRequest {
albumTitle: string;
artistName: string;
mbid?: string;
lastfmUrl?: string;
requestedTracks?: Array<{ title: string; position?: number }>;
}
/**
* Request to acquire individual tracks (for Unknown Album case)
*/
export interface TrackAcquisitionRequest {
trackTitle: string;
artistName: string;
albumTitle?: string;
}
/**
* Result of an acquisition attempt
*/
export interface AcquisitionResult {
success: boolean;
downloadJobId?: number;
source?: "soulseek" | "lidarr";
error?: string;
errorType?: AcquisitionErrorType;
isRecoverable?: boolean;
tracksDownloaded?: number;
tracksTotal?: number;
correlationId?: string;
}
/**
* Service availability check result
*/
interface ServiceAvailability {
lidarrAvailable: boolean;
soulseekAvailable: boolean;
}
/**
* Download behavior matrix configuration
*/
interface DownloadBehavior {
hasPrimarySource: boolean;
primarySource: "soulseek" | "lidarr" | null;
hasFallbackSource: boolean;
fallbackSource: "soulseek" | "lidarr" | null;
}
// ============================================
// ACQUISITION SERVICE
// ============================================
class AcquisitionService {
private albumQueue: PQueue;
constructor() {
// Initialize album queue with concurrency of 2 (configurable)
this.albumQueue = new PQueue({ concurrency: 2 });
logger.debug(
"[Acquisition] Initialized album queue with concurrency=2"
);
}
/**
* Get download behavior configuration (settings + service availability)
* Auto-detects and selects download source based on actual availability
*/
private async getDownloadBehavior(): Promise<DownloadBehavior> {
const settings = await getSystemSettings();
// Get download source settings
const downloadSource = settings?.downloadSource || "soulseek";
const primaryFailureFallback =
settings?.primaryFailureFallback || "none";
// Determine actual availability
const hasSoulseek = await soulseekService.isAvailable();
const hasLidarr = !!(
settings?.lidarrEnabled &&
settings?.lidarrUrl &&
settings?.lidarrApiKey
);
// Case 1: No sources available
if (!hasSoulseek && !hasLidarr) {
logger.debug(
"[Acquisition] Available sources: Lidarr=false, Soulseek=false"
);
logger.error("[Acquisition] No download sources configured");
return {
hasPrimarySource: false,
primarySource: null,
hasFallbackSource: false,
fallbackSource: null,
};
}
// Case 2: Only one source available - use it regardless of preference
if (hasSoulseek && !hasLidarr) {
logger.debug(
"[Acquisition] Available sources: Lidarr=false, Soulseek=true"
);
logger.debug(
"[Acquisition] Using Soulseek as primary source (only source available)"
);
logger.debug(
"[Acquisition] No fallback configured (only one source available)"
);
return {
hasPrimarySource: true,
primarySource: "soulseek",
hasFallbackSource: false,
fallbackSource: null,
};
}
if (hasLidarr && !hasSoulseek) {
logger.debug(
"[Acquisition] Available sources: Lidarr=true, Soulseek=false"
);
logger.debug(
"[Acquisition] Using Lidarr as primary source (only source available)"
);
logger.debug(
"[Acquisition] No fallback configured (only one source available)"
);
return {
hasPrimarySource: true,
primarySource: "lidarr",
hasFallbackSource: false,
fallbackSource: null,
};
}
// Case 3: Both available - respect user preference for primary
const userPrimary = downloadSource; // "soulseek" or "lidarr"
const alternative = userPrimary === "soulseek" ? "lidarr" : "soulseek";
// Auto-enable fallback if both sources are configured and no explicit setting
let useFallback =
primaryFailureFallback !== "none" &&
primaryFailureFallback === alternative;
// Auto-fallback: If both sources available and no explicit fallback set, enable it
if (!useFallback && primaryFailureFallback === "none") {
useFallback = true;
logger.debug(
`[Acquisition] Auto-enabled fallback: ${alternative} (both sources configured)`
);
}
logger.debug(
"[Acquisition] Available sources: Lidarr=true, Soulseek=true"
);
logger.debug(
`[Acquisition] Using ${userPrimary} as primary source (user preference)`
);
logger.debug(
`[Acquisition] Fallback configured: ${
useFallback ? alternative : "none"
}`
);
return {
hasPrimarySource: true,
primarySource: userPrimary,
hasFallbackSource: useFallback,
fallbackSource: useFallback ? alternative : null,
};
}
/**
* Update download job with source-specific status text
* Stored in metadata for frontend display
*/
private async updateJobStatusText(
jobId: string,
source: "lidarr" | "soulseek",
attemptNumber: number
): Promise<void> {
const sourceLabel = source.charAt(0).toUpperCase() + source.slice(1);
const statusText = `${sourceLabel} #${attemptNumber}`;
const job = await prisma.downloadJob.findUnique({
where: { id: jobId },
select: { metadata: true },
});
const existingMetadata = (job?.metadata as any) || {};
await prisma.downloadJob.update({
where: { id: jobId },
data: {
metadata: {
...existingMetadata,
currentSource: source,
lidarrAttempts:
source === "lidarr"
? attemptNumber
: existingMetadata.lidarrAttempts || 0,
soulseekAttempts:
source === "soulseek"
? attemptNumber
: existingMetadata.soulseekAttempts || 0,
statusText,
},
},
});
logger.debug(`[Acquisition] Updated job ${jobId}: ${statusText}`);
}
/**
* Acquire an album using the configured behavior matrix
* Routes to Soulseek or Lidarr based on settings, with fallback support
* Queued to enable parallel album acquisition
*
* @param request - Album to acquire
* @param context - Tracking context (userId, batchId, etc.)
* @returns Acquisition result
*/
async acquireAlbum(
request: AlbumAcquisitionRequest,
context: AcquisitionContext
): Promise<AcquisitionResult> {
return this.albumQueue.add(() =>
this.acquireAlbumInternal(request, context)
);
}
/**
* Internal album acquisition logic (called via queue)
*/
private async acquireAlbumInternal(
request: AlbumAcquisitionRequest,
context: AcquisitionContext
): Promise<AcquisitionResult> {
logger.debug(
`\n[Acquisition] Acquiring album: ${request.artistName} - ${request.albumTitle} (queue: ${this.albumQueue.size} pending, ${this.albumQueue.pending} active)`
);
// Verify artist name before acquisition
try {
const correction = await lastFmService.getArtistCorrection(
request.artistName
);
if (correction?.corrected) {
logger.debug(
`[Acquisition] Artist corrected: "${request.artistName}" → "${correction.canonicalName}"`
);
request = { ...request, artistName: correction.canonicalName };
}
} catch (error) {
logger.warn(
`[Acquisition] Artist correction failed for "${request.artistName}":`,
error
);
}
// Get download behavior configuration
const behavior = await this.getDownloadBehavior();
// Validate at least one source is available
if (!behavior.hasPrimarySource) {
const error =
"No download sources available (neither Soulseek nor Lidarr configured)";
logger.error(`[Acquisition] ${error}`);
return { success: false, error };
}
// Try primary source first
let result: AcquisitionResult;
if (behavior.primarySource === "soulseek") {
logger.debug(`[Acquisition] Trying primary: Soulseek`);
result = await this.acquireAlbumViaSoulseek(request, context);
// Fallback to Lidarr if Soulseek fails and fallback is configured
if (!result.success) {
logger.debug(
`[Acquisition] Soulseek failed: ${result.error || "unknown error"}`
);
logger.debug(
`[Acquisition] Fallback available: hasFallback=${behavior.hasFallbackSource}, source=${behavior.fallbackSource}`
);
if (
behavior.hasFallbackSource &&
behavior.fallbackSource === "lidarr"
) {
logger.debug(
`[Acquisition] Attempting Lidarr fallback...`
);
result = await this.acquireAlbumViaLidarr(request, context);
} else {
logger.debug(
`[Acquisition] No fallback configured or fallback not Lidarr`
);
}
}
} else if (behavior.primarySource === "lidarr") {
logger.debug(`[Acquisition] Trying primary: Lidarr`);
result = await this.acquireAlbumViaLidarr(request, context);
// Fallback to Soulseek if Lidarr fails and fallback is configured
if (!result.success) {
logger.debug(
`[Acquisition] Lidarr failed: ${result.error || "unknown error"}`
);
logger.debug(
`[Acquisition] Fallback available: hasFallback=${behavior.hasFallbackSource}, source=${behavior.fallbackSource}`
);
if (
behavior.hasFallbackSource &&
behavior.fallbackSource === "soulseek"
) {
logger.debug(
`[Acquisition] Attempting Soulseek fallback...`
);
result = await this.acquireAlbumViaSoulseek(request, context);
} else {
logger.debug(
`[Acquisition] No fallback configured or fallback not Soulseek`
);
}
}
} else {
// This should never happen due to validation above
const error = "No primary source configured";
logger.error(`[Acquisition] ${error}`);
return { success: false, error };
}
return result;
}
/**
* Acquire individual tracks via Soulseek (for Unknown Album case)
* Batch downloads tracks without album MBID
*
* @param requests - Tracks to acquire
* @param context - Tracking context
* @returns Array of acquisition results
*/
async acquireTracks(
requests: TrackAcquisitionRequest[],
context: AcquisitionContext
): Promise<AcquisitionResult[]> {
logger.debug(
`\n[Acquisition] Acquiring ${requests.length} individual tracks via Soulseek`
);
// Check Soulseek availability
const soulseekAvailable = await soulseekService.isAvailable();
if (!soulseekAvailable) {
logger.error(
`[Acquisition] Soulseek not available for track downloads`
);
return requests.map(() => ({
success: false,
error: "Soulseek not configured",
}));
}
// Get music path
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
logger.error(`[Acquisition] Music path not configured`);
return requests.map(() => ({
success: false,
error: "Music path not configured",
}));
}
// Prepare tracks for batch download
const tracksToDownload = requests.map((req) => ({
artist: req.artistName,
title: req.trackTitle,
album: req.albumTitle || "Unknown Album",
}));
try {
// Use Soulseek batch download
const batchResult = await soulseekService.searchAndDownloadBatch(
tracksToDownload,
musicPath,
settings?.soulseekConcurrentDownloads || 4 // concurrency
);
logger.debug(
`[Acquisition] Batch result: ${batchResult.successful}/${requests.length} tracks downloaded`
);
// Create individual results for each track
const results: AcquisitionResult[] = requests.map((req, index) => {
// Check if this track was in the successful list
// Note: We don't have per-track success info from batch, so we estimate
const success = index < batchResult.successful;
return {
success,
source: "soulseek" as const,
tracksDownloaded: success ? 1 : 0,
tracksTotal: 1,
error: success
? undefined
: batchResult.errors[index] || "Download failed",
};
});
return results;
} catch (error: any) {
logger.error(
`[Acquisition] Batch track download error: ${error.message}`
);
return requests.map(() => ({
success: false,
error: error.message,
}));
}
}
/**
* Acquire album via Soulseek (track-by-track download)
* Gets track list from MusicBrainz or Last.fm, then batch downloads
* Marks job as completed immediately (no webhook needed)
*
* @param request - Album to acquire
* @param context - Tracking context
* @returns Acquisition result
*/
private async acquireAlbumViaSoulseek(
request: AlbumAcquisitionRequest,
context: AcquisitionContext
): Promise<AcquisitionResult> {
logger.debug(
`[Acquisition/Soulseek] Downloading: ${request.artistName} - ${request.albumTitle}`
);
// Get music path
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
return { success: false, error: "Music path not configured" };
}
if (!request.mbid) {
return {
success: false,
error: "Album MBID required for Soulseek download",
};
}
let job: any;
try {
// Create download job at start for tracking
job = await this.createDownloadJob(request, context);
// Calculate attempt number (existing soulseek attempts + 1)
const jobMetadata = (job.metadata as any) || {};
const soulseekAttempts = (jobMetadata.soulseekAttempts || 0) + 1;
await this.updateJobStatusText(
job.id,
"soulseek",
soulseekAttempts
);
let tracks: Array<{ title: string; position?: number }>;
// If specific tracks requested, use those instead of full album
if (request.requestedTracks && request.requestedTracks.length > 0) {
tracks = request.requestedTracks;
logger.debug(
`[Acquisition/Soulseek] Using ${tracks.length} requested tracks (not full album)`
);
} else {
// Strategy 1: Get track list from MusicBrainz
tracks = await musicBrainzService.getAlbumTracks(request.mbid);
// Strategy 2: Fallback to Last.fm (always try when MusicBrainz fails)
if (!tracks || tracks.length === 0) {
logger.debug(
`[Acquisition/Soulseek] MusicBrainz has no tracks, trying Last.fm`
);
try {
const albumInfo = await lastFmService.getAlbumInfo(
request.artistName,
request.albumTitle
);
const lastFmTracks = albumInfo?.tracks?.track || [];
if (Array.isArray(lastFmTracks) && lastFmTracks.length > 0) {
tracks = lastFmTracks.map((t: any) => ({
title: t.name || t.title,
position: t["@attr"]?.rank
? parseInt(t["@attr"].rank)
: undefined,
}));
logger.debug(
`[Acquisition/Soulseek] Got ${tracks.length} tracks from Last.fm`
);
}
} catch (lastfmError: any) {
logger.warn(
`[Acquisition/Soulseek] Last.fm fallback failed: ${lastfmError.message}`
);
}
}
if (!tracks || tracks.length === 0) {
// Mark job as failed
await this.updateJobStatus(
job.id,
"failed",
"Could not get track list from MusicBrainz or Last.fm"
);
return {
success: false,
error: "Could not get track list from MusicBrainz or Last.fm",
};
}
logger.debug(
`[Acquisition/Soulseek] Found ${tracks.length} tracks for album`
);
}
// Prepare tracks for batch download
const tracksToDownload = tracks.map((track) => ({
artist: request.artistName,
title: track.title,
album: request.albumTitle,
}));
// Use Soulseek batch download (parallel with concurrency limit)
const batchResult = await soulseekService.searchAndDownloadBatch(
tracksToDownload,
musicPath,
settings?.soulseekConcurrentDownloads || 4 // concurrency
);
if (batchResult.successful === 0) {
// Mark job as failed
await this.updateJobStatus(
job.id,
"failed",
`No tracks found on Soulseek (searched ${tracks.length} tracks)`
);
return {
success: false,
tracksTotal: tracks.length,
downloadJobId: parseInt(job.id),
error: `No tracks found on Soulseek (searched ${tracks.length} tracks)`,
};
}
// Success threshold: at least 50% of tracks
const successThreshold = Math.ceil(tracks.length * 0.5);
const isSuccess = batchResult.successful >= successThreshold;
logger.debug(
`[Acquisition/Soulseek] Downloaded ${batchResult.successful}/${tracks.length} tracks (threshold: ${successThreshold})`
);
// Mark job as completed immediately (Soulseek doesn't use webhooks)
await this.updateJobStatus(
job.id,
isSuccess ? "completed" : "failed",
isSuccess
? undefined
: `Only ${batchResult.successful}/${tracks.length} tracks found`
);
// Update job metadata with track counts
await prisma.downloadJob.update({
where: { id: job.id },
data: {
metadata: {
...job.metadata,
tracksDownloaded: batchResult.successful,
tracksTotal: tracks.length,
},
},
});
return {
success: isSuccess,
source: "soulseek",
downloadJobId: parseInt(job.id),
tracksDownloaded: batchResult.successful,
tracksTotal: tracks.length,
error: isSuccess
? undefined
: `Only ${batchResult.successful}/${tracks.length} tracks found`,
};
} catch (error: any) {
logger.error(`[Acquisition/Soulseek] Error: ${error.message}`);
// Update job status if job was created
if (job) {
await this.updateJobStatus(
job.id,
"failed",
error.message
).catch((e) =>
logger.error(
`[Acquisition/Soulseek] Failed to update job status: ${e.message}`
)
);
}
return { success: false, error: error.message };
}
}
/**
* Acquire album via Lidarr (full album download)
* Creates download job and waits for webhook completion
*
* @param request - Album to acquire
* @param context - Tracking context
* @returns Acquisition result
*/
private async acquireAlbumViaLidarr(
request: AlbumAcquisitionRequest,
context: AcquisitionContext
): Promise<AcquisitionResult> {
logger.debug(
`[Acquisition/Lidarr] Downloading: ${request.artistName} - ${request.albumTitle}`
);
if (!request.mbid) {
return {
success: false,
error: "Album MBID required for Lidarr download",
};
}
let job: any;
try {
// Create download job
job = await this.createDownloadJob(request, context);
// Calculate attempt number (existing lidarr attempts + 1)
const jobMetadata = (job.metadata as any) || {};
const lidarrAttempts = (jobMetadata.lidarrAttempts || 0) + 1;
await this.updateJobStatusText(job.id, "lidarr", lidarrAttempts);
// Start Lidarr download
const isDiscovery = !!context.discoveryBatchId;
const result = await simpleDownloadManager.startDownload(
job.id,
request.artistName,
request.albumTitle,
request.mbid,
context.userId,
isDiscovery
);
if (result.success) {
logger.debug(
`[Acquisition/Lidarr] Download started (correlation: ${result.correlationId})`
);
return {
success: true,
source: "lidarr",
downloadJobId: parseInt(job.id),
correlationId: result.correlationId,
};
} else {
logger.error(
`[Acquisition/Lidarr] Failed to start: ${result.error}`
);
// Mark job as failed
await this.updateJobStatus(job.id, "failed", result.error);
// Return structured error info for fallback logic
return {
success: false,
error: result.error,
errorType: result.errorType,
isRecoverable: result.isRecoverable,
};
}
} catch (error: any) {
logger.error(`[Acquisition/Lidarr] Error: ${error.message}`);
// Update job status if job was created
if (job) {
await this.updateJobStatus(
job.id,
"failed",
error.message
).catch((e) =>
logger.error(
`[Acquisition/Lidarr] Failed to update job status: ${e.message}`
)
);
}
return { success: false, error: error.message };
}
}
/**
* Create a DownloadJob for tracking acquisition
* Links to Discovery batch or Spotify import job as appropriate
*
* @param request - Album request
* @param context - Tracking context
* @returns Created download job
*/
private async createDownloadJob(
request: AlbumAcquisitionRequest,
context: AcquisitionContext
): Promise<any> {
// Check for existing job first
if (context.existingJobId) {
logger.debug(
`[Acquisition] Using existing download job: ${context.existingJobId}`
);
return { id: context.existingJobId };
}
// Validate userId before creating download job to prevent foreign key constraint violations
if (!context.userId || typeof context.userId !== 'string' || context.userId === 'NaN' || context.userId === 'undefined' || context.userId === 'null') {
logger.error(
`[Acquisition] Invalid userId in context: ${JSON.stringify({
userId: context.userId,
typeofUserId: typeof context.userId,
albumTitle: request.albumTitle,
artistName: request.artistName
})}`
);
throw new Error(`Invalid userId in acquisition context: ${context.userId}`);
}
const jobData: any = {
userId: context.userId,
subject: `${request.artistName} - ${request.albumTitle}`,
type: "album",
targetMbid: request.mbid || null,
status: "pending",
metadata: {
artistName: request.artistName,
albumTitle: request.albumTitle,
albumMbid: request.mbid,
},
};
// Add context-based tracking
if (context.discoveryBatchId) {
jobData.discoveryBatchId = context.discoveryBatchId;
jobData.metadata.downloadType = "discovery";
}
if (context.spotifyImportJobId) {
jobData.metadata.spotifyImportJobId = context.spotifyImportJobId;
jobData.metadata.downloadType = "spotify_import";
}
const job = await prisma.downloadJob.create({
data: jobData,
});
logger.debug(
`[Acquisition] Created download job: ${job.id} (type: ${
jobData.metadata.downloadType || "library"
})`
);
return job;
}
/**
* Update download job status
*
* @param jobId - Job ID to update
* @param status - New status
* @param error - Optional error message
*/
private async updateJobStatus(
jobId: string,
status: string,
error?: string
): Promise<void> {
await prisma.downloadJob.update({
where: { id: jobId },
data: {
status,
error: error || null,
completedAt:
status === "completed" || status === "failed"
? new Date()
: undefined,
},
});
logger.debug(
`[Acquisition] Updated job ${jobId}: status=${status}${
error ? `, error=${error}` : ""
}`
);
}
}
// Export singleton instance
export const acquisitionService = new AcquisitionService();
@@ -0,0 +1,232 @@
import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import { enrichmentFailureService } from "./enrichmentFailureService";
const STALE_THRESHOLD_MINUTES = 5;
const MAX_RETRIES = 3;
const CIRCUIT_BREAKER_THRESHOLD = 30; // Increased from 10 to handle batch operations
const CIRCUIT_BREAKER_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
type CircuitState = 'closed' | 'open' | 'half-open';
class AudioAnalysisCleanupService {
private state: CircuitState = 'closed';
private failureCount = 0;
private lastFailureTime: Date | null = null;
/**
* Check if we should attempt to transition from open to half-open
*/
private shouldAttemptReset(): boolean {
if (!this.lastFailureTime) return false;
const timeSinceFailure = Date.now() - this.lastFailureTime.getTime();
return timeSinceFailure >= CIRCUIT_BREAKER_WINDOW_MS;
}
/**
* Handle successful operation - close circuit if in half-open state
*/
private onSuccess(): void {
if (this.state === 'half-open') {
logger.info(
`[AudioAnalysisCleanup] Circuit breaker CLOSED - recovery successful after ${this.failureCount} failures`
);
this.state = 'closed';
this.failureCount = 0;
this.lastFailureTime = null;
} else if (this.state === 'closed' && this.failureCount > 0) {
// Reset failure counter on success while closed
logger.debug(
"[AudioAnalysisCleanup] Resetting failure counter on success"
);
this.failureCount = 0;
this.lastFailureTime = null;
}
}
/**
* Handle failed operation - update state and counts
*/
private onFailure(resetCount: number, permanentlyFailedCount: number): void {
const totalFailures = resetCount + permanentlyFailedCount;
this.failureCount += totalFailures;
this.lastFailureTime = new Date();
if (this.state === 'half-open') {
// Failed during half-open - reopen circuit
this.state = 'open';
logger.warn(
`[AudioAnalysisCleanup] Circuit breaker REOPENED - recovery attempt failed (${this.failureCount} total failures)`
);
} else if (this.failureCount >= CIRCUIT_BREAKER_THRESHOLD) {
// Exceeded threshold - open circuit
this.state = 'open';
logger.warn(
`[AudioAnalysisCleanup] Circuit breaker OPEN - ${this.failureCount} failures in window. ` +
`Pausing audio analysis queuing until analyzer shows signs of life.`
);
}
}
/**
* Check if circuit breaker is open (too many consecutive failures)
* Automatically transitions to half-open after cooldown period
*/
isCircuitOpen(): boolean {
if (this.state === 'open' && this.shouldAttemptReset()) {
this.state = 'half-open';
logger.info(
`[AudioAnalysisCleanup] Circuit breaker HALF-OPEN - attempting recovery after ${
CIRCUIT_BREAKER_WINDOW_MS / 60000
} minute cooldown`
);
}
return this.state === 'open';
}
/**
* Record success for external callers (maintains backward compatibility)
*/
recordSuccess(): void {
this.onSuccess();
}
/**
* Clean up tracks stuck in "processing" state
* Returns number of tracks reset and permanently failed
*/
async cleanupStaleProcessing(): Promise<{
reset: number;
permanentlyFailed: number;
}> {
const cutoff = new Date(
Date.now() - STALE_THRESHOLD_MINUTES * 60 * 1000
);
// Find tracks stuck in processing
const staleTracks = await prisma.track.findMany({
where: {
analysisStatus: "processing",
OR: [
{ analysisStartedAt: { lt: cutoff } },
{
analysisStartedAt: null,
updatedAt: { lt: cutoff },
},
],
},
include: {
album: {
include: {
artist: { select: { name: true } },
},
},
},
});
if (staleTracks.length === 0) {
return { reset: 0, permanentlyFailed: 0 };
}
logger.debug(
`[AudioAnalysisCleanup] Found ${staleTracks.length} stale tracks (processing > ${STALE_THRESHOLD_MINUTES} min)`
);
let resetCount = 0;
let permanentlyFailedCount = 0;
for (const track of staleTracks) {
const newRetryCount = (track.analysisRetryCount || 0) + 1;
const trackName = `${track.album.artist.name} - ${track.title}`;
if (newRetryCount >= MAX_RETRIES) {
// Permanently failed - mark as failed and record
await prisma.track.update({
where: { id: track.id },
data: {
analysisStatus: "failed",
analysisError: `Exceeded ${MAX_RETRIES} retry attempts (stale processing)`,
analysisRetryCount: newRetryCount,
analysisStartedAt: null,
},
});
// Record in EnrichmentFailure for user visibility
await enrichmentFailureService.recordFailure({
entityType: "audio",
entityId: track.id,
entityName: trackName,
errorMessage: `Analysis timed out ${MAX_RETRIES} times - track may be corrupted or unsupported`,
errorCode: "MAX_RETRIES_EXCEEDED",
metadata: {
filePath: track.filePath,
retryCount: newRetryCount,
},
});
logger.warn(
`[AudioAnalysisCleanup] Permanently failed: ${trackName}`
);
permanentlyFailedCount++;
} else {
// Reset to pending for retry
await prisma.track.update({
where: { id: track.id },
data: {
analysisStatus: "pending",
analysisStartedAt: null,
analysisRetryCount: newRetryCount,
analysisError: `Reset after stale processing (attempt ${newRetryCount}/${MAX_RETRIES})`,
},
});
logger.debug(
`[AudioAnalysisCleanup] Reset for retry (${newRetryCount}/${MAX_RETRIES}): ${trackName}`
);
resetCount++;
}
}
// Update circuit breaker state
if (resetCount > 0 || permanentlyFailedCount > 0) {
this.onFailure(resetCount, permanentlyFailedCount);
logger.debug(
`[AudioAnalysisCleanup] Cleanup complete: ${resetCount} reset, ${permanentlyFailedCount} permanently failed`
);
}
return { reset: resetCount, permanentlyFailed: permanentlyFailedCount };
}
/**
* Get current analysis statistics
*/
async getStats(): Promise<{
pending: number;
processing: number;
completed: number;
failed: number;
circuitOpen: boolean;
circuitState: CircuitState;
failureCount: number;
}> {
const [pending, processing, completed, failed] = await Promise.all([
prisma.track.count({ where: { analysisStatus: "pending" } }),
prisma.track.count({ where: { analysisStatus: "processing" } }),
prisma.track.count({ where: { analysisStatus: "completed" } }),
prisma.track.count({ where: { analysisStatus: "failed" } }),
]);
return {
pending,
processing,
completed,
failed,
circuitOpen: this.state === 'open',
circuitState: this.state,
failureCount: this.failureCount,
};
}
}
export const audioAnalysisCleanupService = new AudioAnalysisCleanupService();
+108 -16
View File
@@ -1,4 +1,7 @@
import * as fs from "fs"; import * as fs from "fs";
import { promises as fsPromises } from "fs";
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import * as path from "path"; import * as path from "path";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
@@ -50,7 +53,7 @@ export class AudioStreamingService {
// Start cache eviction timer (every 6 hours) // Start cache eviction timer (every 6 hours)
this.evictionInterval = setInterval(() => { this.evictionInterval = setInterval(() => {
this.evictCache(this.transcodeCacheMaxGb).catch((err) => { this.evictCache(this.transcodeCacheMaxGb).catch((err) => {
console.error("Cache eviction failed:", err); logger.error("Cache eviction failed:", err);
}); });
}, 6 * 60 * 60 * 1000); }, 6 * 60 * 60 * 1000);
} }
@@ -64,12 +67,12 @@ export class AudioStreamingService {
sourceModified: Date, sourceModified: Date,
sourceAbsolutePath: string sourceAbsolutePath: string
): Promise<StreamFileInfo> { ): Promise<StreamFileInfo> {
console.log(`[AudioStreaming] Request: trackId=${trackId}, quality=${quality}, source=${path.basename(sourceAbsolutePath)}`); logger.debug(`[AudioStreaming] Request: trackId=${trackId}, quality=${quality}, source=${path.basename(sourceAbsolutePath)}`);
// If original quality requested, return source file // If original quality requested, return source file
if (quality === "original") { if (quality === "original") {
const mimeType = this.getMimeType(sourceAbsolutePath); const mimeType = this.getMimeType(sourceAbsolutePath);
console.log(`[AudioStreaming] Serving original: mimeType=${mimeType}`); logger.debug(`[AudioStreaming] Serving original: mimeType=${mimeType}`);
return { return {
filePath: sourceAbsolutePath, filePath: sourceAbsolutePath,
mimeType, mimeType,
@@ -84,7 +87,7 @@ export class AudioStreamingService {
); );
if (cachedPath) { if (cachedPath) {
console.log( logger.debug(
`[STREAM] Using cached transcode: ${quality} (${cachedPath})` `[STREAM] Using cached transcode: ${quality} (${cachedPath})`
); );
return { return {
@@ -103,7 +106,7 @@ export class AudioStreamingService {
: null; : null;
if (sourceBitrate && sourceBitrate <= targetBitrate) { if (sourceBitrate && sourceBitrate <= targetBitrate) {
console.log( logger.debug(
`[STREAM] Source bitrate (${sourceBitrate}kbps) <= target (${targetBitrate}kbps), serving original` `[STREAM] Source bitrate (${sourceBitrate}kbps) <= target (${targetBitrate}kbps), serving original`
); );
return { return {
@@ -112,7 +115,7 @@ export class AudioStreamingService {
}; };
} }
} catch (err) { } catch (err) {
console.warn( logger.warn(
`[STREAM] Failed to read source metadata, will transcode anyway:`, `[STREAM] Failed to read source metadata, will transcode anyway:`,
err err
); );
@@ -122,7 +125,7 @@ export class AudioStreamingService {
// Need to transcode - check cache size first // Need to transcode - check cache size first
const currentSize = await this.getCacheSize(); const currentSize = await this.getCacheSize();
if (currentSize > this.transcodeCacheMaxGb * 0.9) { if (currentSize > this.transcodeCacheMaxGb * 0.9) {
console.log( logger.debug(
`[STREAM] Cache near full (${currentSize.toFixed( `[STREAM] Cache near full (${currentSize.toFixed(
2 2
)}GB), evicting to 80%...` )}GB), evicting to 80%...`
@@ -131,7 +134,7 @@ export class AudioStreamingService {
} }
// Transcode to cache // Transcode to cache
console.log( logger.debug(
`[STREAM] Transcoding to ${quality} quality: ${sourceAbsolutePath}` `[STREAM] Transcoding to ${quality} quality: ${sourceAbsolutePath}`
); );
const transcodedPath = await this.transcodeToCache( const transcodedPath = await this.transcodeToCache(
@@ -166,7 +169,7 @@ export class AudioStreamingService {
// Invalidate if source file was modified after transcode was created // Invalidate if source file was modified after transcode was created
if (cached.sourceModified < sourceModified) { if (cached.sourceModified < sourceModified) {
console.log( logger.debug(
`[STREAM] Cache stale for track ${trackId}, removing...` `[STREAM] Cache stale for track ${trackId}, removing...`
); );
await prisma.transcodedFile.delete({ where: { id: cached.id } }); await prisma.transcodedFile.delete({ where: { id: cached.id } });
@@ -191,7 +194,7 @@ export class AudioStreamingService {
// Verify file exists // Verify file exists
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
console.log(`[STREAM] Cache file missing: ${fullPath}`); logger.debug(`[STREAM] Cache file missing: ${fullPath}`);
await prisma.transcodedFile.delete({ where: { id: cached.id } }); await prisma.transcodedFile.delete({ where: { id: cached.id } });
return null; return null;
} }
@@ -274,7 +277,7 @@ export class AudioStreamingService {
}, },
}); });
console.log( logger.debug(
`[STREAM] Transcode complete: ${cacheFileName} (${( `[STREAM] Transcode complete: ${cacheFileName} (${(
stats.size / stats.size /
1024 / 1024 /
@@ -322,13 +325,13 @@ export class AudioStreamingService {
* Evict cache using LRU until size is below target * Evict cache using LRU until size is below target
*/ */
async evictCache(targetGb: number): Promise<void> { async evictCache(targetGb: number): Promise<void> {
console.log(`[CACHE] Starting eviction, target: ${targetGb}GB`); logger.debug(`[CACHE] Starting eviction, target: ${targetGb}GB`);
let currentSize = await this.getCacheSize(); let currentSize = await this.getCacheSize();
console.log(`[CACHE] Current size: ${currentSize.toFixed(2)}GB`); logger.debug(`[CACHE] Current size: ${currentSize.toFixed(2)}GB`);
if (currentSize <= targetGb) { if (currentSize <= targetGb) {
console.log("[CACHE] Below target, no eviction needed"); logger.debug("[CACHE] Below target, no eviction needed");
return; return;
} }
@@ -346,7 +349,7 @@ export class AudioStreamingService {
try { try {
await fs.promises.unlink(fullPath); await fs.promises.unlink(fullPath);
} catch (err) { } catch (err) {
console.warn(`[CACHE] Failed to delete ${fullPath}:`, err); logger.warn(`[CACHE] Failed to delete ${fullPath}:`, err);
} }
// Delete from database // Delete from database
@@ -356,7 +359,7 @@ export class AudioStreamingService {
evicted++; evicted++;
} }
console.log( logger.debug(
`[CACHE] Evicted ${evicted} files, new size: ${currentSize.toFixed( `[CACHE] Evicted ${evicted} files, new size: ${currentSize.toFixed(
2 2
)}GB` )}GB`
@@ -383,6 +386,95 @@ export class AudioStreamingService {
return mimeTypes[ext] || "audio/mpeg"; return mimeTypes[ext] || "audio/mpeg";
} }
/**
* Stream file with proper HTTP Range support (fixes Firefox FLAC issue #42/#17)
* Manually handles Range requests to ensure compatibility with Firefox's strict
* Content-Range header validation for large FLAC files.
*/
async streamFileWithRangeSupport(
req: Request,
res: Response,
filePath: string,
mimeType: string
): Promise<void> {
try {
// Get file stats for size
const stats = await fsPromises.stat(filePath);
const fileSize = stats.size;
// Parse Range header
const range = req.headers.range;
let start = 0;
let end = fileSize - 1;
if (range) {
// Parse bytes=START-END or bytes=START-
const parts = range.replace(/bytes=/, "").split("-");
start = parseInt(parts[0], 10);
end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
// Validate range
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416).set({
"Content-Range": `bytes */${fileSize}`,
});
res.end();
return;
}
}
const contentLength = end - start + 1;
// Set response headers
const headers: Record<string, string> = {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Content-Length": contentLength.toString(),
};
// Add CORS headers from request origin
if (req.headers.origin) {
headers["Access-Control-Allow-Origin"] = req.headers.origin;
headers["Access-Control-Allow-Credentials"] = "true";
}
// Set status and range-specific headers
if (range) {
res.status(206);
headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
} else {
res.status(200);
}
res.set(headers);
// Create read stream with range
const stream = fs.createReadStream(filePath, { start, end });
// Handle stream errors
stream.on("error", (err) => {
logger.error(`[AudioStreaming] Stream error for ${filePath}:`, err);
if (!res.headersSent) {
res.status(500).end();
}
});
// Handle cleanup on response close
res.on("close", () => {
stream.destroy();
});
// Pipe stream to response
stream.pipe(res);
} catch (err) {
logger.error(`[AudioStreaming] Failed to stream ${filePath}:`, err);
if (!res.headersSent) {
res.status(500).end();
}
}
}
/** /**
* Cleanup resources * Cleanup resources
*/ */
+64 -28
View File
@@ -1,4 +1,5 @@
import { audiobookshelfService } from "./audiobookshelf"; import { audiobookshelfService } from "./audiobookshelf";
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@@ -19,6 +20,7 @@ interface SyncResult {
export class AudiobookCacheService { export class AudiobookCacheService {
private coverCacheDir: string; private coverCacheDir: string;
private coverCacheAvailable: boolean = false;
constructor() { constructor() {
// Store covers in: <MUSIC_PATH>/cover-cache/audiobooks/ // Store covers in: <MUSIC_PATH>/cover-cache/audiobooks/
@@ -29,6 +31,23 @@ export class AudiobookCacheService {
); );
} }
/**
* Try to ensure cover cache directory exists
* Returns true if available, false if not (permissions issue)
*/
private async ensureCoverCacheDir(): Promise<boolean> {
try {
await fs.mkdir(this.coverCacheDir, { recursive: true });
this.coverCacheAvailable = true;
return true;
} catch (error: any) {
logger.warn(`[AUDIOBOOK] Cover cache directory unavailable: ${error.message}`);
logger.warn("[AUDIOBOOK] Covers will be served directly from Audiobookshelf");
this.coverCacheAvailable = false;
return false;
}
}
/** /**
* Sync all audiobooks from Audiobookshelf to our database * Sync all audiobooks from Audiobookshelf to our database
*/ */
@@ -41,15 +60,15 @@ export class AudiobookCacheService {
}; };
try { try {
console.log(" Starting audiobook sync from Audiobookshelf..."); logger.debug(" Starting audiobook sync from Audiobookshelf...");
// Ensure cover cache directory exists // Try to ensure cover cache directory exists (non-fatal if it fails)
await fs.mkdir(this.coverCacheDir, { recursive: true }); await this.ensureCoverCacheDir();
// Fetch all audiobooks from Audiobookshelf // Fetch all audiobooks from Audiobookshelf
const audiobooks = await audiobookshelfService.getAllAudiobooks(); const audiobooks = await audiobookshelfService.getAllAudiobooks();
console.log( logger.debug(
`[AUDIOBOOK] Found ${audiobooks.length} audiobooks in Audiobookshelf` `[AUDIOBOOK] Found ${audiobooks.length} audiobooks in Audiobookshelf`
); );
@@ -66,7 +85,7 @@ export class AudiobookCacheService {
metadata.author || metadata.author ||
book.author || book.author ||
"Unknown Author"; "Unknown Author";
console.log(` Synced: ${title} by ${author}`); logger.debug(` Synced: ${title} by ${author}`);
} catch (error: any) { } catch (error: any) {
result.failed++; result.failed++;
const metadata = book.media?.metadata || book; const metadata = book.media?.metadata || book;
@@ -74,23 +93,23 @@ export class AudiobookCacheService {
metadata.title || book.title || "Unknown Title"; metadata.title || book.title || "Unknown Title";
const errorMsg = `Failed to sync ${title}: ${error.message}`; const errorMsg = `Failed to sync ${title}: ${error.message}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
console.error(` ${errorMsg}`); logger.error(` ${errorMsg}`);
} }
} }
console.log("\nSync Summary:"); logger.debug("\nSync Summary:");
console.log(` Synced: ${result.synced}`); logger.debug(` Synced: ${result.synced}`);
console.log(` Failed: ${result.failed}`); logger.debug(` Failed: ${result.failed}`);
console.log(` Skipped: ${result.skipped}`); logger.debug(` Skipped: ${result.skipped}`);
if (result.errors.length > 0) { if (result.errors.length > 0) {
console.log("\n[ERRORS]:"); logger.debug("\n[ERRORS]:");
result.errors.forEach((err) => console.log(` - ${err}`)); result.errors.forEach((err) => logger.debug(` - ${err}`));
} }
return result; return result;
} catch (error: any) { } catch (error: any) {
console.error(" Audiobook sync failed:", error); logger.error(" Audiobook sync failed:", error);
throw error; throw error;
} }
} }
@@ -106,7 +125,7 @@ export class AudiobookCacheService {
// Skip if no title (invalid audiobook data) // Skip if no title (invalid audiobook data)
if (!title) { if (!title) {
console.warn(` Skipping audiobook ${book.id} - missing title`); logger.warn(` Skipping audiobook ${book.id} - missing title`);
return; return;
} }
@@ -187,7 +206,7 @@ export class AudiobookCacheService {
// Log series info for debugging (only for first few books) // Log series info for debugging (only for first few books)
if (series) { if (series) {
console.log( logger.debug(
` [Series] "${title}" -> "${series}" #${ ` [Series] "${title}" -> "${series}" #${
seriesSequence || "?" seriesSequence || "?"
}` }`
@@ -281,7 +300,7 @@ export class AudiobookCacheService {
return null; return null;
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
"Failed to get Audiobookshelf base URL:", "Failed to get Audiobookshelf base URL:",
error.message error.message
); );
@@ -291,11 +310,17 @@ export class AudiobookCacheService {
/** /**
* Download a cover image and save it locally * Download a cover image and save it locally
* Returns null if cover caching is not available (permissions issue)
*/ */
private async downloadCover( private async downloadCover(
audiobookId: string, audiobookId: string,
coverUrl: string coverUrl: string
): Promise<string> { ): Promise<string | null> {
// Skip cover download if cache directory is not available
if (!this.coverCacheAvailable) {
return null;
}
try { try {
// Get API key for authentication // Get API key for authentication
const { getSystemSettings } = await import( const { getSystemSettings } = await import(
@@ -327,11 +352,11 @@ export class AudiobookCacheService {
return filePath; return filePath;
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
`Failed to download cover for ${audiobookId}:`, `Failed to download cover for ${audiobookId}:`,
error.message error.message
); );
return null as any; // Return null if download fails return null;
} }
} }
@@ -350,7 +375,7 @@ export class AudiobookCacheService {
audiobook.lastSyncedAt < audiobook.lastSyncedAt <
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) { ) {
console.log( logger.debug(
`[AUDIOBOOK] Audiobook ${audiobookId} not cached or stale, syncing...` `[AUDIOBOOK] Audiobook ${audiobookId} not cached or stale, syncing...`
); );
try { try {
@@ -362,13 +387,13 @@ export class AudiobookCacheService {
where: { id: audiobookId }, where: { id: audiobookId },
}); });
} catch (syncError: any) { } catch (syncError: any) {
console.warn( logger.warn(
` Failed to sync audiobook ${audiobookId} from Audiobookshelf:`, ` Failed to sync audiobook ${audiobookId} from Audiobookshelf:`,
syncError.message syncError.message
); );
// If we have stale cached data, return it anyway // If we have stale cached data, return it anyway
if (audiobook) { if (audiobook) {
console.log( logger.debug(
` Using stale cached data for ${audiobookId}` ` Using stale cached data for ${audiobookId}`
); );
} else { } else {
@@ -387,6 +412,13 @@ export class AudiobookCacheService {
* Clean up old cached covers that are no longer in database * Clean up old cached covers that are no longer in database
*/ */
async cleanupOrphanedCovers(): Promise<number> { async cleanupOrphanedCovers(): Promise<number> {
// Ensure cache directory is available
const available = await this.ensureCoverCacheDir();
if (!available) {
logger.warn("[AUDIOBOOK] Cannot cleanup covers - cache directory unavailable");
return 0;
}
const audiobooks = await prisma.audiobook.findMany({ const audiobooks = await prisma.audiobook.findMany({
select: { localCoverPath: true }, select: { localCoverPath: true },
}); });
@@ -398,14 +430,18 @@ export class AudiobookCacheService {
); );
let deleted = 0; let deleted = 0;
const files = await fs.readdir(this.coverCacheDir); try {
const files = await fs.readdir(this.coverCacheDir);
for (const file of files) { for (const file of files) {
if (!validCoverPaths.has(file)) { if (!validCoverPaths.has(file)) {
await fs.unlink(path.join(this.coverCacheDir, file)); await fs.unlink(path.join(this.coverCacheDir, file));
deleted++; deleted++;
console.log(` [DELETE] Deleted orphaned cover: ${file}`); logger.debug(` [DELETE] Deleted orphaned cover: ${file}`);
}
} }
} catch (error: any) {
logger.warn(`[AUDIOBOOK] Failed to read cover cache directory: ${error.message}`);
} }
return deleted; return deleted;
+132 -11
View File
@@ -1,5 +1,7 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { logger } from "../utils/logger";
import { getSystemSettings } from "../utils/systemSettings"; import { getSystemSettings } from "../utils/systemSettings";
import { prisma } from "../utils/db";
/** /**
* Audiobookshelf API Service * Audiobookshelf API Service
@@ -33,13 +35,13 @@ class AudiobookshelfService {
this.baseUrl = settings.audiobookshelfUrl.replace(/\/$/, ""); // Remove trailing slash this.baseUrl = settings.audiobookshelfUrl.replace(/\/$/, ""); // Remove trailing slash
this.apiKey = settings.audiobookshelfApiKey; this.apiKey = settings.audiobookshelfApiKey;
this.client = axios.create({ this.client = axios.create({
baseURL: this.baseUrl, baseURL: this.baseUrl as string,
headers: { headers: {
Authorization: `Bearer ${this.apiKey}`, Authorization: `Bearer ${this.apiKey}`,
}, },
timeout: 30000, // 30 seconds for remote server timeout: 30000, // 30 seconds for remote server
}); });
console.log("Audiobookshelf configured from database"); logger.debug("Audiobookshelf configured from database");
this.initialized = true; this.initialized = true;
return; return;
} }
@@ -47,7 +49,7 @@ class AudiobookshelfService {
if (error.message === "Audiobookshelf is disabled in settings") { if (error.message === "Audiobookshelf is disabled in settings") {
throw error; throw error;
} }
console.log( logger.debug(
" Could not load Audiobookshelf from database, checking .env" " Could not load Audiobookshelf from database, checking .env"
); );
} }
@@ -66,7 +68,7 @@ class AudiobookshelfService {
}, },
timeout: 30000, // 30 seconds for remote server timeout: 30000, // 30 seconds for remote server
}); });
console.log("Audiobookshelf configured from .env"); logger.debug("Audiobookshelf configured from .env");
this.initialized = true; this.initialized = true;
} else { } else {
throw new Error("Audiobookshelf not configured"); throw new Error("Audiobookshelf not configured");
@@ -82,7 +84,7 @@ class AudiobookshelfService {
const response = await this.client!.get("/api/libraries"); const response = await this.client!.get("/api/libraries");
return response.status === 200; return response.status === 200;
} catch (error) { } catch (error) {
console.error("Audiobookshelf connection failed:", error); logger.error("Audiobookshelf connection failed:", error);
return false; return false;
} }
} }
@@ -122,16 +124,22 @@ class AudiobookshelfService {
// DEBUG: Log the structure of the first item with series // DEBUG: Log the structure of the first item with series
if (items.length > 0) { if (items.length > 0) {
const itemsWithSeries = items.filter((item: any) => const itemsWithSeries = items.filter(
item.media?.metadata?.series || item.media?.metadata?.seriesName (item: any) =>
item.media?.metadata?.series ||
item.media?.metadata?.seriesName
); );
if (itemsWithSeries.length > 0) { if (itemsWithSeries.length > 0) {
console.log( logger.debug(
"[AUDIOBOOKSHELF DEBUG] Sample item WITH series:", "[AUDIOBOOKSHELF DEBUG] Sample item WITH series:",
JSON.stringify(itemsWithSeries[0], null, 2).substring(0, 2000) JSON.stringify(
itemsWithSeries[0],
null,
2
).substring(0, 2000)
); );
} else { } else {
console.log( logger.debug(
"[AUDIOBOOKSHELF DEBUG] No items with series found! Sample item:", "[AUDIOBOOKSHELF DEBUG] No items with series found! Sample item:",
JSON.stringify(items[0], null, 2).substring(0, 1000) JSON.stringify(items[0], null, 2).substring(0, 1000)
); );
@@ -169,7 +177,7 @@ class AudiobookshelfService {
try { try {
return await this.getLibraryItems(library.id); return await this.getLibraryItems(library.id);
} catch (error) { } catch (error) {
console.error( logger.error(
`Audiobookshelf: failed to load podcast library ${library.id}`, `Audiobookshelf: failed to load podcast library ${library.id}`,
error error
); );
@@ -330,6 +338,119 @@ class AudiobookshelfService {
); );
return response.data.book || []; return response.data.book || [];
} }
/**
* Sync audiobooks from Audiobookshelf to local database cache
* This populates the Audiobook table for full-text search
*/
async syncAudiobooksToCache() {
await this.ensureInitialized();
logger.debug("[AUDIOBOOKSHELF] Starting audiobook sync to cache...");
try {
// Fetch all audiobooks from Audiobookshelf API
const audiobooks = await this.getAllAudiobooks();
logger.debug(
`[AUDIOBOOKSHELF] Found ${audiobooks.length} audiobooks to sync`
);
// Map and upsert each audiobook to database
let syncedCount = 0;
for (const item of audiobooks) {
try {
const metadata = item.media?.metadata || {};
// Extract series information (check both possible formats)
let series: string | null = null;
let seriesSequence: string | null = null;
if (metadata.series && Array.isArray(metadata.series) && metadata.series.length > 0) {
series = metadata.series[0].name || null;
seriesSequence = metadata.series[0].sequence || null;
} else if (metadata.seriesName) {
series = metadata.seriesName;
seriesSequence = metadata.seriesSequence || null;
}
await prisma.audiobook.upsert({
where: { id: item.id },
update: {
title: metadata.title || "Untitled",
author: metadata.authorName || metadata.author || null,
narrator: metadata.narratorName || metadata.narrator || null,
description: metadata.description || null,
publishedYear: metadata.publishedYear
? parseInt(metadata.publishedYear, 10)
: null,
publisher: metadata.publisher || null,
series,
seriesSequence,
duration: item.media?.duration || null,
numTracks: item.media?.numTracks || null,
numChapters: item.media?.numChapters || null,
size: item.media?.size
? BigInt(item.media.size)
: null,
isbn: metadata.isbn || null,
asin: metadata.asin || null,
language: metadata.language || null,
genres: metadata.genres || [],
tags: item.media?.tags || [],
coverUrl: metadata.coverPath
? `${this.baseUrl}${metadata.coverPath}`
: null,
audioUrl: `${this.baseUrl}/api/items/${item.id}/play`,
libraryId: item.libraryId || null,
lastSyncedAt: new Date(),
},
create: {
id: item.id,
title: metadata.title || "Untitled",
author: metadata.authorName || metadata.author || null,
narrator: metadata.narratorName || metadata.narrator || null,
description: metadata.description || null,
publishedYear: metadata.publishedYear
? parseInt(metadata.publishedYear, 10)
: null,
publisher: metadata.publisher || null,
series,
seriesSequence,
duration: item.media?.duration || null,
numTracks: item.media?.numTracks || null,
numChapters: item.media?.numChapters || null,
size: item.media?.size
? BigInt(item.media.size)
: null,
isbn: metadata.isbn || null,
asin: metadata.asin || null,
language: metadata.language || null,
genres: metadata.genres || [],
tags: item.media?.tags || [],
coverUrl: metadata.coverPath
? `${this.baseUrl}${metadata.coverPath}`
: null,
audioUrl: `${this.baseUrl}/api/items/${item.id}/play`,
libraryId: item.libraryId || null,
},
});
syncedCount++;
} catch (error) {
logger.error(
`[AUDIOBOOKSHELF] Failed to sync audiobook ${item.id}:`,
error
);
}
}
logger.debug(
`[AUDIOBOOKSHELF] Successfully synced ${syncedCount}/${audiobooks.length} audiobooks to cache`
);
return { synced: syncedCount, total: audiobooks.length };
} catch (error) {
logger.error("[AUDIOBOOKSHELF] Audiobook sync failed:", error);
throw error;
}
}
} }
export const audiobookshelfService = new AudiobookshelfService(); export const audiobookshelfService = new AudiobookshelfService();
+4 -3
View File
@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { logger } from "../utils/logger";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { rateLimiter } from "./rateLimiter"; import { rateLimiter } from "./rateLimiter";
@@ -13,7 +14,7 @@ class CoverArtService {
if (cached === "NOT_FOUND") return null; // Cached negative result if (cached === "NOT_FOUND") return null; // Cached negative result
if (cached) return cached; if (cached) return cached;
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -35,7 +36,7 @@ class CoverArtService {
try { try {
await redisClient.setEx(cacheKey, 2592000, coverUrl); // 30 days await redisClient.setEx(cacheKey, 2592000, coverUrl); // 30 days
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return coverUrl; return coverUrl;
@@ -57,7 +58,7 @@ class CoverArtService {
} }
return null; return null;
} }
console.error(`Cover art error for ${rgMbid}:`, error.message); logger.error(`Cover art error for ${rgMbid}:`, error.message);
} }
return null; return null;
+3 -2
View File
@@ -1,4 +1,5 @@
import * as fs from "fs"; import * as fs from "fs";
import { logger } from "../utils/logger";
import * as path from "path"; import * as path from "path";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { parseFile } from "music-metadata"; import { parseFile } from "music-metadata";
@@ -44,13 +45,13 @@ export class CoverArtExtractor {
// Save to cache // Save to cache
await fs.promises.writeFile(cachePath, picture.data); await fs.promises.writeFile(cachePath, picture.data);
console.log( logger.debug(
`[COVER-ART] Extracted cover art from ${path.basename(audioFilePath)}: ${cacheFileName}` `[COVER-ART] Extracted cover art from ${path.basename(audioFilePath)}: ${cacheFileName}`
); );
return cacheFileName; return cacheFileName;
} catch (err) { } catch (err) {
console.error( logger.error(
`[COVER-ART] Failed to extract from ${audioFilePath}:`, `[COVER-ART] Failed to extract from ${audioFilePath}:`,
err err
); );
+62 -35
View File
@@ -10,6 +10,7 @@
* - All fetched data is persisted for future use * - All fetched data is persisted for future use
*/ */
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { fanartService } from "./fanart"; import { fanartService } from "./fanart";
@@ -38,15 +39,16 @@ class DataCacheService {
try { try {
const artist = await prisma.artist.findUnique({ const artist = await prisma.artist.findUnique({
where: { id: artistId }, where: { id: artistId },
select: { heroUrl: true }, select: { heroUrl: true, userHeroUrl: true },
}); });
if (artist?.heroUrl) { const displayHeroUrl = artist?.userHeroUrl ?? artist?.heroUrl;
if (displayHeroUrl) {
// Also populate Redis for faster future reads // Also populate Redis for faster future reads
this.setRedisCache(cacheKey, artist.heroUrl, ARTIST_IMAGE_TTL); this.setRedisCache(cacheKey, displayHeroUrl, ARTIST_IMAGE_TTL);
return artist.heroUrl; return displayHeroUrl;
} }
} catch (err) { } catch (err) {
console.warn("[DataCache] DB lookup failed for artist:", artistId); logger.warn("[DataCache] DB lookup failed for artist:", artistId);
} }
// 2. Check Redis cache // 2. Check Redis cache
@@ -98,7 +100,7 @@ class DataCacheService {
return album.coverUrl; return album.coverUrl;
} }
} catch (err) { } catch (err) {
console.warn("[DataCache] DB lookup failed for album:", albumId); logger.warn("[DataCache] DB lookup failed for album:", albumId);
} }
// 2. Check Redis cache // 2. Check Redis cache
@@ -155,14 +157,15 @@ class DataCacheService {
* Only returns what's already cached, doesn't make API calls * Only returns what's already cached, doesn't make API calls
*/ */
async getArtistImagesBatch( async getArtistImagesBatch(
artists: Array<{ id: string; heroUrl?: string | null }> artists: Array<{ id: string; heroUrl?: string | null; userHeroUrl?: string | null }>
): Promise<Map<string, string | null>> { ): Promise<Map<string, string | null>> {
const results = new Map<string, string | null>(); const results = new Map<string, string | null>();
// First, use any heroUrls already in the data // First, use any heroUrls/userHeroUrls already in the data (with override pattern)
for (const artist of artists) { for (const artist of artists) {
if (artist.heroUrl) { const displayHeroUrl = artist.userHeroUrl ?? artist.heroUrl;
results.set(artist.id, artist.heroUrl); if (displayHeroUrl) {
results.set(artist.id, displayHeroUrl);
} }
} }
@@ -242,7 +245,7 @@ class DataCacheService {
try { try {
heroUrl = await fanartService.getArtistImage(mbid); heroUrl = await fanartService.getArtistImage(mbid);
if (heroUrl) { if (heroUrl) {
console.log(`[DataCache] Got image from Fanart.tv for ${artistName}`); logger.debug(`[DataCache] Got image from Fanart.tv for ${artistName}`);
return heroUrl; return heroUrl;
} }
} catch (err) { } catch (err) {
@@ -254,7 +257,7 @@ class DataCacheService {
try { try {
heroUrl = await deezerService.getArtistImage(artistName); heroUrl = await deezerService.getArtistImage(artistName);
if (heroUrl) { if (heroUrl) {
console.log(`[DataCache] Got image from Deezer for ${artistName}`); logger.debug(`[DataCache] Got image from Deezer for ${artistName}`);
return heroUrl; return heroUrl;
} }
} catch (err) { } catch (err) {
@@ -275,7 +278,7 @@ class DataCacheService {
// Filter out Last.fm placeholder images // Filter out Last.fm placeholder images
const imageUrl = largestImage["#text"]; const imageUrl = largestImage["#text"];
if (!imageUrl.includes("2a96cbd8b46e442fc41c2b86b821562f")) { if (!imageUrl.includes("2a96cbd8b46e442fc41c2b86b821562f")) {
console.log(`[DataCache] Got image from Last.fm for ${artistName}`); logger.debug(`[DataCache] Got image from Last.fm for ${artistName}`);
return imageUrl; return imageUrl;
} }
} }
@@ -284,7 +287,7 @@ class DataCacheService {
// Last.fm failed // Last.fm failed
} }
console.log(`[DataCache] No image found for ${artistName}`); logger.debug(`[DataCache] No image found for ${artistName}`);
return null; return null;
} }
@@ -298,7 +301,7 @@ class DataCacheService {
data: { heroUrl }, data: { heroUrl },
}); });
} catch (err) { } catch (err) {
console.warn("[DataCache] Failed to update artist heroUrl:", err); logger.warn("[DataCache] Failed to update artist heroUrl:", err);
} }
} }
@@ -312,7 +315,7 @@ class DataCacheService {
data: { coverUrl }, data: { coverUrl },
}); });
} catch (err) { } catch (err) {
console.warn("[DataCache] Failed to update album coverUrl:", err); logger.warn("[DataCache] Failed to update album coverUrl:", err);
} }
} }
@@ -327,12 +330,32 @@ class DataCacheService {
} }
} }
/**
* Set multiple Redis cache entries using pipelining
* Uses MULTI/EXEC for atomic batch writes
*/
private async setRedisCacheBatch(
entries: Array<{ key: string; value: string; ttl: number }>
): Promise<void> {
if (entries.length === 0) return;
try {
const multi = redisClient.multi();
for (const { key, value, ttl } of entries) {
multi.setEx(key, ttl, value);
}
await multi.exec();
} catch (err) {
logger.warn("[DataCache] Batch cache write failed:", err);
}
}
/** /**
* Warm up Redis cache from database * Warm up Redis cache from database
* Called on server startup * Called on server startup
*/ */
async warmupCache(): Promise<void> { async warmupCache(): Promise<void> {
console.log("[DataCache] Warming up Redis cache from database..."); logger.debug("[DataCache] Warming up Redis cache from database...");
try { try {
// Warm up artist images // Warm up artist images
@@ -341,14 +364,16 @@ class DataCacheService {
select: { id: true, heroUrl: true }, select: { id: true, heroUrl: true },
}); });
let artistCount = 0; const artistEntries = artists
for (const artist of artists) { .filter((a) => a.heroUrl)
if (artist.heroUrl) { .map((a) => ({
await this.setRedisCache(`hero:${artist.id}`, artist.heroUrl, ARTIST_IMAGE_TTL); key: `hero:${a.id}`,
artistCount++; value: a.heroUrl!,
} ttl: ARTIST_IMAGE_TTL,
} }));
console.log(`[DataCache] Cached ${artistCount} artist images`);
await this.setRedisCacheBatch(artistEntries);
logger.debug(`[DataCache] Cached ${artistEntries.length} artist images`);
// Warm up album covers // Warm up album covers
const albums = await prisma.album.findMany({ const albums = await prisma.album.findMany({
@@ -356,18 +381,20 @@ class DataCacheService {
select: { id: true, coverUrl: true }, select: { id: true, coverUrl: true },
}); });
let albumCount = 0; const albumEntries = albums
for (const album of albums) { .filter((a) => a.coverUrl)
if (album.coverUrl) { .map((a) => ({
await this.setRedisCache(`album-cover:${album.id}`, album.coverUrl, ALBUM_COVER_TTL); key: `album-cover:${a.id}`,
albumCount++; value: a.coverUrl!,
} ttl: ALBUM_COVER_TTL,
} }));
console.log(`[DataCache] Cached ${albumCount} album covers`);
console.log("[DataCache] Cache warmup complete"); await this.setRedisCacheBatch(albumEntries);
logger.debug(`[DataCache] Cached ${albumEntries.length} album covers`);
logger.debug("[DataCache] Cache warmup complete");
} catch (err) { } catch (err) {
console.error("[DataCache] Cache warmup failed:", err); logger.error("[DataCache] Cache warmup failed:", err);
} }
} }
} }
+32 -31
View File
@@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { logger } from "../utils/logger";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
/** /**
@@ -91,7 +92,7 @@ class DeezerService {
*/ */
private async setCache(key: string, value: string): Promise<void> { private async setCache(key: string, value: string): Promise<void> {
try { try {
await redisClient.setex(`${this.cachePrefix}${key}`, this.cacheTTL, value); await redisClient.setEx(`${this.cachePrefix}${key}`, this.cacheTTL, value);
} catch { } catch {
// Ignore cache errors // Ignore cache errors
} }
@@ -121,7 +122,7 @@ class DeezerService {
await this.setCache(cacheKey, imageUrl || "null"); await this.setCache(cacheKey, imageUrl || "null");
return imageUrl; return imageUrl;
} catch (error: any) { } catch (error: any) {
console.error(`Deezer artist image error for ${artistName}:`, error.message); logger.error(`Deezer artist image error for ${artistName}:`, error.message);
return null; return null;
} }
} }
@@ -157,7 +158,7 @@ class DeezerService {
await this.setCache(cacheKey, coverUrl || "null"); await this.setCache(cacheKey, coverUrl || "null");
return coverUrl; return coverUrl;
} catch (error: any) { } catch (error: any) {
console.error(`Deezer album cover error for ${artistName} - ${albumName}:`, error.message); logger.error(`Deezer album cover error for ${artistName} - ${albumName}:`, error.message);
return null; return null;
} }
} }
@@ -182,7 +183,7 @@ class DeezerService {
await this.setCache(cacheKey, previewUrl || "null"); await this.setCache(cacheKey, previewUrl || "null");
return previewUrl; return previewUrl;
} catch (error: any) { } catch (error: any) {
console.error(`Deezer track preview error for ${artistName} - ${trackName}:`, error.message); logger.error(`Deezer track preview error for ${artistName} - ${trackName}:`, error.message);
return null; return null;
} }
} }
@@ -218,7 +219,7 @@ class DeezerService {
*/ */
async getPlaylist(playlistId: string): Promise<DeezerPlaylist | null> { async getPlaylist(playlistId: string): Promise<DeezerPlaylist | null> {
try { try {
console.log(`Deezer: Fetching playlist ${playlistId}...`); logger.debug(`Deezer: Fetching playlist ${playlistId}...`);
const response = await axios.get(`${DEEZER_API}/playlist/${playlistId}`, { const response = await axios.get(`${DEEZER_API}/playlist/${playlistId}`, {
timeout: 15000, timeout: 15000,
@@ -226,7 +227,7 @@ class DeezerService {
const data = response.data; const data = response.data;
if (data.error) { if (data.error) {
console.error("Deezer API error:", data.error); logger.error("Deezer API error:", data.error);
return null; return null;
} }
@@ -242,7 +243,7 @@ class DeezerService {
coverUrl: track.album?.cover_medium || track.album?.cover || null, coverUrl: track.album?.cover_medium || track.album?.cover || null,
})); }));
console.log(`Deezer: Fetched playlist "${data.title}" with ${tracks.length} tracks`); logger.debug(`Deezer: Fetched playlist "${data.title}" with ${tracks.length} tracks`);
return { return {
id: String(data.id), id: String(data.id),
@@ -255,7 +256,7 @@ class DeezerService {
isPublic: data.public ?? true, isPublic: data.public ?? true,
}; };
} catch (error: any) { } catch (error: any) {
console.error("Deezer playlist fetch error:", error.message); logger.error("Deezer playlist fetch error:", error.message);
return null; return null;
} }
} }
@@ -280,7 +281,7 @@ class DeezerService {
fans: playlist.fans || 0, fans: playlist.fans || 0,
})); }));
} catch (error: any) { } catch (error: any) {
console.error("Deezer chart playlists error:", error.message); logger.error("Deezer chart playlists error:", error.message);
return []; return [];
} }
} }
@@ -305,7 +306,7 @@ class DeezerService {
fans: 0, fans: 0,
})); }));
} catch (error: any) { } catch (error: any) {
console.error("Deezer playlist search error:", error.message); logger.error("Deezer playlist search error:", error.message);
return []; return [];
} }
} }
@@ -319,7 +320,7 @@ class DeezerService {
const cacheKey = `playlists:featured:${limit}`; const cacheKey = `playlists:featured:${limit}`;
const cached = await this.getCached(cacheKey); const cached = await this.getCached(cacheKey);
if (cached) { if (cached) {
console.log("Deezer: Returning cached featured playlists"); logger.debug("Deezer: Returning cached featured playlists");
return JSON.parse(cached); return JSON.parse(cached);
} }
@@ -328,7 +329,7 @@ class DeezerService {
const seenIds = new Set<string>(); const seenIds = new Set<string>();
// 1. Get chart playlists (max 99 available) // 1. Get chart playlists (max 99 available)
console.log("Deezer: Fetching chart playlists from API..."); logger.debug("Deezer: Fetching chart playlists from API...");
const chartPlaylists = await this.getChartPlaylists(Math.min(limit, 99)); const chartPlaylists = await this.getChartPlaylists(Math.min(limit, 99));
for (const p of chartPlaylists) { for (const p of chartPlaylists) {
if (!seenIds.has(p.id)) { if (!seenIds.has(p.id)) {
@@ -336,7 +337,7 @@ class DeezerService {
allPlaylists.push(p); allPlaylists.push(p);
} }
} }
console.log(`Deezer: Got ${chartPlaylists.length} chart playlists`); logger.debug(`Deezer: Got ${chartPlaylists.length} chart playlists`);
// 2. If we need more, search for popular genre playlists // 2. If we need more, search for popular genre playlists
if (allPlaylists.length < limit) { if (allPlaylists.length < limit) {
@@ -360,11 +361,11 @@ class DeezerService {
} }
const result = allPlaylists.slice(0, limit); const result = allPlaylists.slice(0, limit);
console.log(`Deezer: Caching ${result.length} featured playlists`); logger.debug(`Deezer: Caching ${result.length} featured playlists`);
await this.setCache(cacheKey, JSON.stringify(result)); await this.setCache(cacheKey, JSON.stringify(result));
return result; return result;
} catch (error: any) { } catch (error: any) {
console.error("Deezer featured playlists error:", error.message); logger.error("Deezer featured playlists error:", error.message);
return []; return [];
} }
} }
@@ -380,12 +381,12 @@ class DeezerService {
const cacheKey = "genres:all"; const cacheKey = "genres:all";
const cached = await this.getCached(cacheKey); const cached = await this.getCached(cacheKey);
if (cached) { if (cached) {
console.log("Deezer: Returning cached genres"); logger.debug("Deezer: Returning cached genres");
return JSON.parse(cached); return JSON.parse(cached);
} }
try { try {
console.log("Deezer: Fetching genres from API..."); logger.debug("Deezer: Fetching genres from API...");
const response = await axios.get(`${DEEZER_API}/genre`, { const response = await axios.get(`${DEEZER_API}/genre`, {
timeout: 10000, timeout: 10000,
}); });
@@ -398,11 +399,11 @@ class DeezerService {
imageUrl: genre.picture_medium || genre.picture || null, imageUrl: genre.picture_medium || genre.picture || null,
})); }));
console.log(`Deezer: Caching ${genres.length} genres`); logger.debug(`Deezer: Caching ${genres.length} genres`);
await this.setCache(cacheKey, JSON.stringify(genres)); await this.setCache(cacheKey, JSON.stringify(genres));
return genres; return genres;
} catch (error: any) { } catch (error: any) {
console.error("Deezer genres error:", error.message); logger.error("Deezer genres error:", error.message);
return []; return [];
} }
} }
@@ -426,12 +427,12 @@ class DeezerService {
const cacheKey = "radio:stations"; const cacheKey = "radio:stations";
const cached = await this.getCached(cacheKey); const cached = await this.getCached(cacheKey);
if (cached) { if (cached) {
console.log("Deezer: Returning cached radio stations"); logger.debug("Deezer: Returning cached radio stations");
return JSON.parse(cached); return JSON.parse(cached);
} }
try { try {
console.log("Deezer: Fetching radio stations from API..."); logger.debug("Deezer: Fetching radio stations from API...");
const response = await axios.get(`${DEEZER_API}/radio`, { const response = await axios.get(`${DEEZER_API}/radio`, {
timeout: 10000, timeout: 10000,
}); });
@@ -444,11 +445,11 @@ class DeezerService {
type: "radio" as const, type: "radio" as const,
})); }));
console.log(`Deezer: Got ${stations.length} radio stations, caching...`); logger.debug(`Deezer: Got ${stations.length} radio stations, caching...`);
await this.setCache(cacheKey, JSON.stringify(stations)); await this.setCache(cacheKey, JSON.stringify(stations));
return stations; return stations;
} catch (error: any) { } catch (error: any) {
console.error("Deezer radio stations error:", error.message); logger.error("Deezer radio stations error:", error.message);
return []; return [];
} }
} }
@@ -464,12 +465,12 @@ class DeezerService {
const cacheKey = "radio:by-genre"; const cacheKey = "radio:by-genre";
const cached = await this.getCached(cacheKey); const cached = await this.getCached(cacheKey);
if (cached) { if (cached) {
console.log("Deezer: Returning cached radios by genre"); logger.debug("Deezer: Returning cached radios by genre");
return JSON.parse(cached); return JSON.parse(cached);
} }
try { try {
console.log("Deezer: Fetching radios by genre from API..."); logger.debug("Deezer: Fetching radios by genre from API...");
const response = await axios.get(`${DEEZER_API}/radio/genres`, { const response = await axios.get(`${DEEZER_API}/radio/genres`, {
timeout: 10000, timeout: 10000,
}); });
@@ -486,11 +487,11 @@ class DeezerService {
})), })),
})); }));
console.log(`Deezer: Got ${genres.length} genre categories with radios, caching...`); logger.debug(`Deezer: Got ${genres.length} genre categories with radios, caching...`);
await this.setCache(cacheKey, JSON.stringify(genres)); await this.setCache(cacheKey, JSON.stringify(genres));
return genres; return genres;
} catch (error: any) { } catch (error: any) {
console.error("Deezer radios by genre error:", error.message); logger.error("Deezer radios by genre error:", error.message);
return []; return [];
} }
} }
@@ -500,7 +501,7 @@ class DeezerService {
*/ */
async getRadioTracks(radioId: string): Promise<DeezerPlaylist | null> { async getRadioTracks(radioId: string): Promise<DeezerPlaylist | null> {
try { try {
console.log(`Deezer: Fetching radio ${radioId} tracks...`); logger.debug(`Deezer: Fetching radio ${radioId} tracks...`);
// First get radio info // First get radio info
const infoResponse = await axios.get(`${DEEZER_API}/radio/${radioId}`, { const infoResponse = await axios.get(`${DEEZER_API}/radio/${radioId}`, {
@@ -526,7 +527,7 @@ class DeezerService {
coverUrl: track.album?.cover_medium || track.album?.cover || null, coverUrl: track.album?.cover_medium || track.album?.cover || null,
})); }));
console.log(`Deezer: Fetched radio "${radioInfo.title}" with ${tracks.length} tracks`); logger.debug(`Deezer: Fetched radio "${radioInfo.title}" with ${tracks.length} tracks`);
return { return {
id: `radio-${radioId}`, id: `radio-${radioId}`,
@@ -539,7 +540,7 @@ class DeezerService {
isPublic: true, isPublic: true,
}; };
} catch (error: any) { } catch (error: any) {
console.error("Deezer radio tracks error:", error.message); logger.error("Deezer radio tracks error:", error.message);
return null; return null;
} }
} }
@@ -578,7 +579,7 @@ class DeezerService {
return { playlists, radios }; return { playlists, radios };
} catch (error: any) { } catch (error: any) {
console.error("Deezer editorial content error:", error.message); logger.error("Deezer editorial content error:", error.message);
return { playlists: [], radios: [] }; return { playlists: [], radios: [] };
} }
} }
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,4 +1,5 @@
import * as fs from "fs"; import * as fs from "fs";
import { logger } from "../utils/logger";
import * as path from "path"; import * as path from "path";
/** /**
@@ -59,7 +60,7 @@ class DiscoveryLogger {
} }
// Also write to console for real-time visibility // Also write to console for real-time visibility
console.log(message); logger.debug(message);
} }
/** /**
+135 -67
View File
@@ -1,3 +1,5 @@
import { logger } from "../utils/logger";
interface DownloadInfo { interface DownloadInfo {
downloadId: string; downloadId: string;
albumTitle: string; albumTitle: string;
@@ -72,15 +74,15 @@ class DownloadQueueManager {
}; };
this.activeDownloads.set(downloadId, info); this.activeDownloads.set(downloadId, info);
console.log( logger.debug(
`[DOWNLOAD] Started: "${albumTitle}" by ${artistName} (${downloadId})` `[DOWNLOAD] Started: "${albumTitle}" by ${artistName} (${downloadId})`
); );
console.log(` Album MBID: ${albumMbid}`); logger.debug(` Album MBID: ${albumMbid}`);
console.log(` Active downloads: ${this.activeDownloads.size}`); logger.debug(` Active downloads: ${this.activeDownloads.size}`);
// Persist Lidarr download reference to download job for later status updates // Persist Lidarr download reference to download job for later status updates
this.linkDownloadJob(downloadId, albumMbid).catch((error) => { this.linkDownloadJob(downloadId, albumMbid).catch((error) => {
console.error(` linkDownloadJob error:`, error); logger.error(` linkDownloadJob error:`, error);
}); });
// Start timeout on first download // Start timeout on first download
@@ -108,12 +110,12 @@ class DownloadQueueManager {
*/ */
async completeDownload(downloadId: string, albumTitle: string) { async completeDownload(downloadId: string, albumTitle: string) {
this.activeDownloads.delete(downloadId); this.activeDownloads.delete(downloadId);
console.log(`Download complete: "${albumTitle}" (${downloadId})`); logger.debug(`Download complete: "${albumTitle}" (${downloadId})`);
console.log(` Remaining downloads: ${this.activeDownloads.size}`); logger.debug(` Remaining downloads: ${this.activeDownloads.size}`);
// If no more downloads, trigger refresh immediately // If no more downloads, trigger refresh immediately
if (this.activeDownloads.size === 0) { if (this.activeDownloads.size === 0) {
console.log(`⏰ All downloads complete! Starting refresh now...`); logger.debug(`⏰ All downloads complete! Starting refresh now...`);
this.clearTimeout(); this.clearTimeout();
this.triggerFullRefresh(); this.triggerFullRefresh();
} }
@@ -125,29 +127,29 @@ class DownloadQueueManager {
async failDownload(downloadId: string, reason: string) { async failDownload(downloadId: string, reason: string) {
const info = this.activeDownloads.get(downloadId); const info = this.activeDownloads.get(downloadId);
if (!info) { if (!info) {
console.log( logger.debug(
` Download ${downloadId} not tracked, ignoring failure` ` Download ${downloadId} not tracked, ignoring failure`
); );
return; return;
} }
console.log(` Download failed: "${info.albumTitle}" (${downloadId})`); logger.debug(` Download failed: "${info.albumTitle}" (${downloadId})`);
console.log(` Reason: ${reason}`); logger.debug(` Reason: ${reason}`);
console.log(` Attempt ${info.attempts}/${this.MAX_RETRY_ATTEMPTS}`); logger.debug(` Attempt ${info.attempts}/${this.MAX_RETRY_ATTEMPTS}`);
// Check if we should retry // Check if we should retry
if (info.attempts < this.MAX_RETRY_ATTEMPTS) { if (info.attempts < this.MAX_RETRY_ATTEMPTS) {
info.attempts++; info.attempts++;
console.log(` Retrying download... (attempt ${info.attempts})`); logger.debug(` Retrying download... (attempt ${info.attempts})`);
await this.retryDownload(info); await this.retryDownload(info);
} else { } else {
console.log(` ⛔ Max retry attempts reached, giving up`); logger.debug(` ⛔ Max retry attempts reached, giving up`);
await this.cleanupFailedAlbum(info); await this.cleanupFailedAlbum(info);
this.activeDownloads.delete(downloadId); this.activeDownloads.delete(downloadId);
// Check if all downloads are done // Check if all downloads are done
if (this.activeDownloads.size === 0) { if (this.activeDownloads.size === 0) {
console.log( logger.debug(
`⏰ All downloads finished (some failed). Starting refresh...` `⏰ All downloads finished (some failed). Starting refresh...`
); );
this.clearTimeout(); this.clearTimeout();
@@ -162,7 +164,7 @@ class DownloadQueueManager {
private async retryDownload(info: DownloadInfo) { private async retryDownload(info: DownloadInfo) {
try { try {
if (!info.albumId) { if (!info.albumId) {
console.log(` No album ID, cannot retry`); logger.debug(` No album ID, cannot retry`);
return; return;
} }
@@ -176,7 +178,7 @@ class DownloadQueueManager {
!settings.lidarrUrl || !settings.lidarrUrl ||
!settings.lidarrApiKey !settings.lidarrApiKey
) { ) {
console.log(` Lidarr not configured`); logger.debug(` Lidarr not configured`);
return; return;
} }
@@ -195,9 +197,9 @@ class DownloadQueueManager {
} }
); );
console.log(` Retry search triggered in Lidarr`); logger.debug(` Retry search triggered in Lidarr`);
} catch (error: any) { } catch (error: any) {
console.log(` Failed to retry: ${error.message}`); logger.debug(` Failed to retry: ${error.message}`);
} }
} }
@@ -206,7 +208,7 @@ class DownloadQueueManager {
*/ */
private async cleanupFailedAlbum(info: DownloadInfo) { private async cleanupFailedAlbum(info: DownloadInfo) {
try { try {
console.log(` Cleaning up failed album: ${info.albumTitle}`); logger.debug(` Cleaning up failed album: ${info.albumTitle}`);
const { getSystemSettings } = await import( const { getSystemSettings } = await import(
"../utils/systemSettings" "../utils/systemSettings"
@@ -233,9 +235,9 @@ class DownloadQueueManager {
timeout: 10000, timeout: 10000,
} }
); );
console.log(` Removed album from Lidarr`); logger.debug(` Removed album from Lidarr`);
} catch (error: any) { } catch (error: any) {
console.log(` Failed to remove album: ${error.message}`); logger.debug(` Failed to remove album: ${error.message}`);
} }
} }
@@ -264,27 +266,27 @@ class DownloadQueueManager {
timeout: 10000, timeout: 10000,
} }
); );
console.log( logger.debug(
` Removed artist from Lidarr (no other albums)` ` Removed artist from Lidarr (no other albums)`
); );
} }
} catch (error: any) { } catch (error: any) {
console.log( logger.debug(
` Failed to check/remove artist: ${error.message}` ` Failed to check/remove artist: ${error.message}`
); );
} }
} }
// Mark as failed in Discovery database // Mark as deleted in Discovery database (closest to failed status)
const { prisma } = await import("../utils/db"); const { prisma } = await import("../utils/db");
await prisma.discoveryAlbum.updateMany({ await prisma.discoveryAlbum.updateMany({
where: { albumTitle: info.albumTitle }, where: { albumTitle: info.albumTitle },
data: { status: "FAILED" }, data: { status: "DELETED" },
}); });
console.log(` Marked as failed in database`); logger.debug(` Marked as failed in database`);
// Notify callbacks about unavailable album // Notify callbacks about unavailable album
console.log( logger.debug(
` [NOTIFY] Notifying ${this.unavailableCallbacks.length} callbacks about unavailable album` ` [NOTIFY] Notifying ${this.unavailableCallbacks.length} callbacks about unavailable album`
); );
for (const callback of this.unavailableCallbacks) { for (const callback of this.unavailableCallbacks) {
@@ -299,11 +301,11 @@ class DownloadQueueManager {
similarity: info.similarity, similarity: info.similarity,
}); });
} catch (error: any) { } catch (error: any) {
console.log(` Callback error: ${error.message}`); logger.debug(` Callback error: ${error.message}`);
} }
} }
} catch (error: any) { } catch (error: any) {
console.log(` Cleanup error: ${error.message}`); logger.debug(` Cleanup error: ${error.message}`);
} }
} }
@@ -312,20 +314,20 @@ class DownloadQueueManager {
*/ */
private startTimeout() { private startTimeout() {
const timeoutMs = this.TIMEOUT_MINUTES * 60 * 1000; const timeoutMs = this.TIMEOUT_MINUTES * 60 * 1000;
console.log( logger.debug(
`[TIMER] Starting ${this.TIMEOUT_MINUTES}-minute timeout for automatic scan` `[TIMER] Starting ${this.TIMEOUT_MINUTES}-minute timeout for automatic scan`
); );
this.timeoutTimer = setTimeout(() => { this.timeoutTimer = setTimeout(() => {
if (this.activeDownloads.size > 0) { if (this.activeDownloads.size > 0) {
console.log( logger.debug(
`\n Timeout reached! ${this.activeDownloads.size} downloads still pending.` `\n Timeout reached! ${this.activeDownloads.size} downloads still pending.`
); );
console.log(` These downloads never completed:`); logger.debug(` These downloads never completed:`);
// Mark each pending download as failed to trigger callbacks // Mark each pending download as failed to trigger callbacks
for (const [downloadId, info] of this.activeDownloads) { for (const [downloadId, info] of this.activeDownloads) {
console.log( logger.debug(
` - ${info.albumTitle} by ${info.artistName}` ` - ${info.albumTitle} by ${info.artistName}`
); );
// This will trigger the unavailable album callback // This will trigger the unavailable album callback
@@ -333,14 +335,14 @@ class DownloadQueueManager {
downloadId, downloadId,
"Download timeout - never completed" "Download timeout - never completed"
).catch((err) => { ).catch((err) => {
console.error( logger.error(
`Error failing download ${downloadId}:`, `Error failing download ${downloadId}:`,
err err
); );
}); });
} }
console.log( logger.debug(
` Triggering scan anyway to process completed downloads...\n` ` Triggering scan anyway to process completed downloads...\n`
); );
} else { } else {
@@ -364,27 +366,27 @@ class DownloadQueueManager {
*/ */
private async triggerFullRefresh() { private async triggerFullRefresh() {
try { try {
console.log("\n Starting full library refresh...\n"); logger.debug("\n Starting full library refresh...\n");
// Step 1: Clear failed imports from Lidarr // Step 1: Clear failed imports from Lidarr
console.log("[1/2] Checking for failed imports in Lidarr..."); logger.debug("[1/2] Checking for failed imports in Lidarr...");
await this.clearFailedLidarrImports(); await this.clearFailedLidarrImports();
// Step 2: Trigger Lidify library sync // Step 2: Trigger Lidify library sync
console.log("[2/2] Triggering Lidify library sync..."); logger.debug("[2/2] Triggering Lidify library sync...");
const lidifySuccess = await this.triggerLidifySync(); const lidifySuccess = await this.triggerLidifySync();
if (!lidifySuccess) { if (!lidifySuccess) {
console.error(" Lidify sync failed"); logger.error(" Lidify sync failed");
return; return;
} }
console.log("Lidify sync started"); logger.debug("Lidify sync started");
console.log( logger.debug(
"\n[SUCCESS] Full library refresh complete! New music should appear shortly.\n" "\n[SUCCESS] Full library refresh complete! New music should appear shortly.\n"
); );
} catch (error) { } catch (error) {
console.error(" Library refresh error:", error); logger.error(" Library refresh error:", error);
} }
} }
@@ -399,7 +401,7 @@ class DownloadQueueManager {
const settings = await getSystemSettings(); const settings = await getSystemSettings();
if (!settings.lidarrEnabled || !settings.lidarrUrl) { if (!settings.lidarrEnabled || !settings.lidarrUrl) {
console.log(" Lidarr not configured, skipping"); logger.debug(" Lidarr not configured, skipping");
return; return;
} }
@@ -408,7 +410,7 @@ class DownloadQueueManager {
// Get Lidarr API key // Get Lidarr API key
const apiKey = settings.lidarrApiKey; const apiKey = settings.lidarrApiKey;
if (!apiKey) { if (!apiKey) {
console.log(" Lidarr API key not found, skipping"); logger.debug(" Lidarr API key not found, skipping");
return; return;
} }
@@ -433,11 +435,11 @@ class DownloadQueueManager {
); );
if (failed.length === 0) { if (failed.length === 0) {
console.log(" No failed imports found"); logger.debug(" No failed imports found");
return; return;
} }
console.log(` Found ${failed.length} failed import(s)`); logger.debug(` Found ${failed.length} failed import(s)`);
for (const item of failed) { for (const item of failed) {
const artistName = const artistName =
@@ -445,7 +447,7 @@ class DownloadQueueManager {
const albumTitle = const albumTitle =
item.album?.title || item.album?.name || "Unknown Album"; item.album?.title || item.album?.name || "Unknown Album";
console.log(` ${artistName} - ${albumTitle}`); logger.debug(` ${artistName} - ${albumTitle}`);
try { try {
// Remove from queue, blocklist, and trigger search // Remove from queue, blocklist, and trigger search
@@ -474,22 +476,22 @@ class DownloadQueueManager {
timeout: 10000, timeout: 10000,
} }
); );
console.log( logger.debug(
` → Blocklisted and searching for alternative` ` → Blocklisted and searching for alternative`
); );
} else { } else {
console.log( logger.debug(
` → Blocklisted (no album ID for re-search)` ` → Blocklisted (no album ID for re-search)`
); );
} }
} catch (error: any) { } catch (error: any) {
console.log(` Failed to process: ${error.message}`); logger.debug(` Failed to process: ${error.message}`);
} }
} }
console.log(` Cleared ${failed.length} failed import(s)`); logger.debug(` Cleared ${failed.length} failed import(s)`);
} catch (error: any) { } catch (error: any) {
console.log(` Failed to check Lidarr queue: ${error.message}`); logger.debug(` Failed to check Lidarr queue: ${error.message}`);
} }
} }
@@ -501,12 +503,12 @@ class DownloadQueueManager {
const { scanQueue } = await import("../workers/queues"); const { scanQueue } = await import("../workers/queues");
const { prisma } = await import("../utils/db"); const { prisma } = await import("../utils/db");
console.log(" Starting library scan..."); logger.debug(" Starting library scan...");
// Get first user for scanning // Get first user for scanning
const firstUser = await prisma.user.findFirst(); const firstUser = await prisma.user.findFirst();
if (!firstUser) { if (!firstUser) {
console.error(` No users found in database, cannot scan`); logger.error(` No users found in database, cannot scan`);
return false; return false;
} }
@@ -516,10 +518,10 @@ class DownloadQueueManager {
source: "download-queue", source: "download-queue",
}); });
console.log("Library scan queued"); logger.debug("Library scan queued");
return true; return true;
} catch (error: any) { } catch (error: any) {
console.error("Lidify sync trigger error:", error.message); logger.error("Lidify sync trigger error:", error.message);
return false; return false;
} }
} }
@@ -546,7 +548,7 @@ class DownloadQueueManager {
* Manually trigger a full refresh (for testing or manual triggers) * Manually trigger a full refresh (for testing or manual triggers)
*/ */
async manualRefresh() { async manualRefresh() {
console.log("\n Manual refresh triggered...\n"); logger.debug("\n Manual refresh triggered...\n");
await this.triggerFullRefresh(); await this.triggerFullRefresh();
} }
@@ -561,7 +563,7 @@ class DownloadQueueManager {
for (const [downloadId, info] of this.activeDownloads) { for (const [downloadId, info] of this.activeDownloads) {
const age = now - info.startTime; const age = now - info.startTime;
if (age > this.STALE_TIMEOUT_MS) { if (age > this.STALE_TIMEOUT_MS) {
console.log( logger.debug(
`[CLEANUP] Cleaning up stale download: "${ `[CLEANUP] Cleaning up stale download: "${
info.albumTitle info.albumTitle
}" (${downloadId}) - age: ${Math.round( }" (${downloadId}) - age: ${Math.round(
@@ -574,7 +576,7 @@ class DownloadQueueManager {
} }
if (cleanedCount > 0) { if (cleanedCount > 0) {
console.log( logger.debug(
`[CLEANUP] Cleaned up ${cleanedCount} stale download(s)` `[CLEANUP] Cleaned up ${cleanedCount} stale download(s)`
); );
} }
@@ -582,6 +584,71 @@ class DownloadQueueManager {
return cleanedCount; return cleanedCount;
} }
/**
* Reconcile in-memory state with database on startup
* - Mark stale jobs (>30 min without update) as failed
* - Load active/processing jobs into memory
*/
async reconcileOnStartup(): Promise<{ loaded: number; failed: number }> {
const { prisma } = await import("../utils/db");
const staleThreshold = new Date(Date.now() - this.STALE_TIMEOUT_MS);
// Mark stale processing jobs as failed
const staleResult = await prisma.downloadJob.updateMany({
where: {
status: "processing",
startedAt: { lt: staleThreshold }
},
data: {
status: "failed",
error: "Server restart - download was processing but never completed"
}
});
logger.debug(`[DOWNLOAD] Marked ${staleResult.count} stale downloads as failed`);
// Load recent processing jobs into memory (not stale)
const activeJobs = await prisma.downloadJob.findMany({
where: {
status: "processing",
startedAt: { gte: staleThreshold }
},
select: {
id: true,
subject: true,
targetMbid: true,
lidarrRef: true,
metadata: true,
startedAt: true,
attempts: true
}
});
// Populate in-memory map from database
for (const job of activeJobs) {
const metadata = job.metadata as Record<string, any> || {};
this.activeDownloads.set(job.lidarrRef || job.id, {
downloadId: job.lidarrRef || job.id,
albumTitle: job.subject,
albumMbid: job.targetMbid,
artistName: metadata.artistName || "Unknown",
artistMbid: metadata.artistMbid,
albumId: metadata.lidarrAlbumId,
artistId: metadata.lidarrArtistId,
attempts: job.attempts,
startTime: job.startedAt?.getTime() || Date.now(),
userId: metadata.userId,
tier: metadata.tier,
similarity: metadata.similarity
});
}
logger.debug(`[DOWNLOAD] Loaded ${activeJobs.length} active downloads from database`);
return { loaded: activeJobs.length, failed: staleResult.count };
}
/** /**
* Shutdown the download queue manager (cleanup resources) * Shutdown the download queue manager (cleanup resources)
*/ */
@@ -592,14 +659,14 @@ class DownloadQueueManager {
} }
this.clearTimeout(); this.clearTimeout();
this.activeDownloads.clear(); this.activeDownloads.clear();
console.log("Download queue manager shutdown"); logger.debug("Download queue manager shutdown");
} }
/** /**
* Link Lidarr download IDs to download jobs (so we can mark them completed later) * Link Lidarr download IDs to download jobs (so we can mark them completed later)
*/ */
private async linkDownloadJob(downloadId: string, albumMbid: string) { private async linkDownloadJob(downloadId: string, albumMbid: string) {
console.log( logger.debug(
` [LINK] Attempting to link download job for MBID: ${albumMbid}` ` [LINK] Attempting to link download job for MBID: ${albumMbid}`
); );
try { try {
@@ -615,7 +682,7 @@ class DownloadQueueManager {
targetMbid: true, targetMbid: true,
}, },
}); });
console.log( logger.debug(
` [LINK] Found ${existingJobs.length} job(s) with this MBID:`, ` [LINK] Found ${existingJobs.length} job(s) with this MBID:`,
JSON.stringify(existingJobs, null, 2) JSON.stringify(existingJobs, null, 2)
); );
@@ -629,27 +696,28 @@ class DownloadQueueManager {
data: { data: {
lidarrRef: downloadId, lidarrRef: downloadId,
status: "processing", status: "processing",
startedAt: new Date(),
}, },
}); });
if (result.count === 0) { if (result.count === 0) {
console.log( logger.debug(
` No matching download jobs found to link with Lidarr ID ${downloadId}` ` No matching download jobs found to link with Lidarr ID ${downloadId}`
); );
console.log( logger.debug(
` This means either: no job exists, job already has lidarrRef, or status is not pending/processing` ` This means either: no job exists, job already has lidarrRef, or status is not pending/processing`
); );
} else { } else {
console.log( logger.debug(
` Linked Lidarr download ${downloadId} to ${result.count} download job(s)` ` Linked Lidarr download ${downloadId} to ${result.count} download job(s)`
); );
} }
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
` Failed to persist Lidarr download link:`, ` Failed to persist Lidarr download link:`,
error.message error.message
); );
console.error(` Error details:`, error); logger.error(` Error details:`, error);
} }
} }
} }
+31 -28
View File
@@ -14,6 +14,7 @@
* - Manual override support * - Manual override support
*/ */
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { lastFmService } from "./lastfm"; import { lastFmService } from "./lastfm";
import { musicBrainzService } from "./musicbrainz"; import { musicBrainzService } from "./musicbrainz";
@@ -171,7 +172,7 @@ export class EnrichmentService {
throw new Error(`Artist ${artistId} not found`); throw new Error(`Artist ${artistId} not found`);
} }
console.log(`Enriching artist: ${artist.name}`); logger.debug(`Enriching artist: ${artist.name}`);
const enrichmentData: ArtistEnrichmentData = { const enrichmentData: ArtistEnrichmentData = {
confidence: 0, confidence: 0,
@@ -190,10 +191,10 @@ export class EnrichmentService {
if (mbResults.length > 0) { if (mbResults.length > 0) {
enrichmentData.mbid = mbResults[0].id; enrichmentData.mbid = mbResults[0].id;
enrichmentData.confidence += 0.4; enrichmentData.confidence += 0.4;
console.log(` Found MBID: ${enrichmentData.mbid}`); logger.debug(` Found MBID: ${enrichmentData.mbid}`);
} }
} catch (error) { } catch (error) {
console.error(` MusicBrainz lookup failed:`, error); logger.error(` MusicBrainz lookup failed:`, error);
} }
} }
@@ -214,7 +215,7 @@ export class EnrichmentService {
lastfmInfo.tags?.tag?.map((t: any) => t.name) || []; lastfmInfo.tags?.tag?.map((t: any) => t.name) || [];
enrichmentData.genres = enrichmentData.tags?.slice(0, 3); // Top 3 tags as genres enrichmentData.genres = enrichmentData.tags?.slice(0, 3); // Top 3 tags as genres
enrichmentData.confidence += 0.3; enrichmentData.confidence += 0.3;
console.log( logger.debug(
` Found Last.fm data: ${ ` Found Last.fm data: ${
enrichmentData.tags?.length || 0 enrichmentData.tags?.length || 0
} tags` } tags`
@@ -228,10 +229,10 @@ export class EnrichmentService {
enrichmentData.similarArtists = similar.map( enrichmentData.similarArtists = similar.map(
(a: any) => a.name (a: any) => a.name
); );
console.log(` Found ${similar.length} similar artists`); logger.debug(` Found ${similar.length} similar artists`);
} }
} catch (error) { } catch (error) {
console.error( logger.error(
` ✗ Last.fm lookup failed:`, ` ✗ Last.fm lookup failed:`,
error instanceof Error ? error.message : error error instanceof Error ? error.message : error
); );
@@ -251,16 +252,16 @@ export class EnrichmentService {
if (imageResult) { if (imageResult) {
enrichmentData.heroUrl = imageResult.url; enrichmentData.heroUrl = imageResult.url;
enrichmentData.confidence += 0.2; enrichmentData.confidence += 0.2;
console.log(` Found artist image from ${imageResult.source}`); logger.debug(` Found artist image from ${imageResult.source}`);
} }
} catch (error) { } catch (error) {
console.error( logger.error(
` ✗ Artist image lookup failed:`, ` ✗ Artist image lookup failed:`,
error instanceof Error ? error.message : error error instanceof Error ? error.message : error
); );
} }
console.log( logger.debug(
` Enrichment confidence: ${( ` Enrichment confidence: ${(
enrichmentData.confidence * 100 enrichmentData.confidence * 100
).toFixed(0)}%` ).toFixed(0)}%`
@@ -294,7 +295,7 @@ export class EnrichmentService {
throw new Error(`Album ${albumId} not found`); throw new Error(`Album ${albumId} not found`);
} }
console.log( logger.debug(
`[Enrichment] Processing album: ${album.artist.name} - ${album.title}` `[Enrichment] Processing album: ${album.artist.name} - ${album.title}`
); );
@@ -335,7 +336,7 @@ export class EnrichmentService {
? new Date(match["first-release-date"]) ? new Date(match["first-release-date"])
: undefined; : undefined;
enrichmentData.confidence += 0.5; enrichmentData.confidence += 0.5;
console.log(` Found MBID: ${enrichmentData.rgMbid}`); logger.debug(` Found MBID: ${enrichmentData.rgMbid}`);
// Try to get label info from first release // Try to get label info from first release
try { try {
@@ -355,18 +356,18 @@ export class EnrichmentService {
) { ) {
enrichmentData.label = enrichmentData.label =
releaseInfo["label-info"][0].label.name; releaseInfo["label-info"][0].label.name;
console.log( logger.debug(
` Found label: ${enrichmentData.label}` ` Found label: ${enrichmentData.label}`
); );
} }
} }
} catch (error) { } catch (error) {
console.log(`Could not fetch label info`); logger.debug(`Could not fetch label info`);
} }
} }
} }
} catch (error) { } catch (error) {
console.error(` MusicBrainz lookup failed:`, error); logger.error(` MusicBrainz lookup failed:`, error);
} }
} }
@@ -375,8 +376,7 @@ export class EnrichmentService {
try { try {
const lastfmInfo = await lastFmService.getAlbumInfo( const lastfmInfo = await lastFmService.getAlbumInfo(
album.artist.name, album.artist.name,
album.title, album.title
enrichmentData.rgMbid
); );
if (lastfmInfo) { if (lastfmInfo) {
@@ -386,14 +386,14 @@ export class EnrichmentService {
enrichmentData.trackCount = enrichmentData.trackCount =
lastfmInfo.tracks?.track?.length; lastfmInfo.tracks?.track?.length;
enrichmentData.confidence += 0.3; enrichmentData.confidence += 0.3;
console.log( logger.debug(
` Found Last.fm data: ${ ` Found Last.fm data: ${
enrichmentData.tags?.length || 0 enrichmentData.tags?.length || 0
} tags` } tags`
); );
} }
} catch (error) { } catch (error) {
console.error(` Last.fm lookup failed:`, error); logger.error(` Last.fm lookup failed:`, error);
} }
} }
@@ -408,16 +408,16 @@ export class EnrichmentService {
if (coverResult) { if (coverResult) {
enrichmentData.coverUrl = coverResult.url; enrichmentData.coverUrl = coverResult.url;
enrichmentData.confidence += 0.2; enrichmentData.confidence += 0.2;
console.log(` Found cover art from ${coverResult.source}`); logger.debug(` Found cover art from ${coverResult.source}`);
} }
} catch (error) { } catch (error) {
console.error( logger.error(
` ✗ Cover art lookup failed:`, ` ✗ Cover art lookup failed:`,
error instanceof Error ? error.message : error error instanceof Error ? error.message : error
); );
} }
console.log( logger.debug(
` Enrichment confidence: ${( ` Enrichment confidence: ${(
enrichmentData.confidence * 100 enrichmentData.confidence * 100
).toFixed(0)}%` ).toFixed(0)}%`
@@ -443,7 +443,7 @@ export class EnrichmentService {
}); });
if (existingArtist && existingArtist.id !== artistId) { if (existingArtist && existingArtist.id !== artistId) {
console.log( logger.debug(
`MBID ${data.mbid} already used by "${existingArtist.name}", skipping MBID update` `MBID ${data.mbid} already used by "${existingArtist.name}", skipping MBID update`
); );
} else { } else {
@@ -462,7 +462,7 @@ export class EnrichmentService {
where: { id: artistId }, where: { id: artistId },
data: updateData, data: updateData,
}); });
console.log( logger.debug(
` Saved ${data.genres?.length || 0} genres for artist` ` Saved ${data.genres?.length || 0} genres for artist`
); );
} }
@@ -480,6 +480,9 @@ export class EnrichmentService {
if (data.rgMbid) updateData.rgMbid = data.rgMbid; if (data.rgMbid) updateData.rgMbid = data.rgMbid;
if (data.coverUrl) updateData.coverUrl = data.coverUrl; if (data.coverUrl) updateData.coverUrl = data.coverUrl;
if (data.releaseDate) { if (data.releaseDate) {
// Store original release date in dedicated field
updateData.originalYear = data.releaseDate.getFullYear();
// Also update year for backward compatibility (but originalYear takes precedence)
updateData.year = data.releaseDate.getFullYear(); updateData.year = data.releaseDate.getFullYear();
} }
if (data.label) updateData.label = data.label; if (data.label) updateData.label = data.label;
@@ -492,7 +495,7 @@ export class EnrichmentService {
where: { id: albumId }, where: { id: albumId },
data: updateData, data: updateData,
}); });
console.log( logger.debug(
` Saved album data: ${ ` Saved album data: ${
data.genres?.length || 0 data.genres?.length || 0
} genres, label: ${data.label || "none"}` } genres, label: ${data.label || "none"}`
@@ -565,7 +568,7 @@ export class EnrichmentService {
}, },
}); });
console.log(`Starting enrichment for ${artists.length} artists...`); logger.debug(`Starting enrichment for ${artists.length} artists...`);
for (const artist of artists) { for (const artist of artists) {
try { try {
@@ -634,7 +637,7 @@ export class EnrichmentService {
item: `${artist.name} - ${album.title}`, item: `${artist.name} - ${album.title}`,
error: error.message, error: error.message,
}); });
console.error( logger.error(
` ✗ Failed to enrich ${artist.name} - ${album.title}:`, ` ✗ Failed to enrich ${artist.name} - ${album.title}:`,
error error
); );
@@ -649,11 +652,11 @@ export class EnrichmentService {
item: artist.name, item: artist.name,
error: error.message, error: error.message,
}); });
console.error(` Failed to enrich ${artist.name}:`, error); logger.error(` Failed to enrich ${artist.name}:`, error);
} }
} }
console.log( logger.debug(
`Enrichment complete: ${result.itemsEnriched}/${result.itemsProcessed} items enriched` `Enrichment complete: ${result.itemsEnriched}/${result.itemsProcessed} items enriched`
); );
@@ -0,0 +1,354 @@
/**
* Enrichment Failure Service
*
* Tracks and manages failures during artist/track/audio enrichment.
* Provides visibility into what failed and allows selective retry.
*/
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
export interface EnrichmentFailure {
id: string;
entityType: "artist" | "track" | "audio";
entityId: string;
entityName: string | null;
errorMessage: string | null;
errorCode: string | null;
retryCount: number;
maxRetries: number;
firstFailedAt: Date;
lastFailedAt: Date;
skipped: boolean;
skippedAt: Date | null;
resolved: boolean;
resolvedAt: Date | null;
metadata: any;
}
export interface RecordFailureInput {
entityType: "artist" | "track" | "audio";
entityId: string;
entityName?: string;
errorMessage: string;
errorCode?: string;
metadata?: any;
}
export interface GetFailuresOptions {
entityType?: "artist" | "track" | "audio";
includeSkipped?: boolean;
includeResolved?: boolean;
limit?: number;
offset?: number;
}
class EnrichmentFailureService {
/**
* Record a failure (or increment retry count if already exists)
*/
async recordFailure(input: RecordFailureInput): Promise<EnrichmentFailure> {
const {
entityType,
entityId,
entityName,
errorMessage,
errorCode,
metadata,
} = input;
// Try to find existing failure
const existing = await prisma.enrichmentFailure.findUnique({
where: {
entityType_entityId: {
entityType,
entityId,
},
},
});
if (existing) {
// Update existing failure - cap retry count at maxRetries to prevent unbounded increment
const newRetryCount = Math.min(
existing.retryCount + 1,
existing.maxRetries
);
return await prisma.enrichmentFailure.update({
where: { id: existing.id },
data: {
errorMessage,
errorCode,
retryCount: newRetryCount,
lastFailedAt: new Date(),
metadata: metadata
? JSON.parse(JSON.stringify(metadata))
: existing.metadata,
},
}) as EnrichmentFailure;
} else {
// Create new failure
return await prisma.enrichmentFailure.create({
data: {
entityType,
entityId,
entityName,
errorMessage,
errorCode,
retryCount: 1,
maxRetries: 3,
metadata: metadata
? JSON.parse(JSON.stringify(metadata))
: null,
},
}) as EnrichmentFailure;
}
}
/**
* Get failures with filtering and pagination
*/
async getFailures(options: GetFailuresOptions = {}): Promise<{
failures: EnrichmentFailure[];
total: number;
}> {
const {
entityType,
includeSkipped = false,
includeResolved = false,
limit = 100,
offset = 0,
} = options;
const where: any = {};
if (entityType) {
where.entityType = entityType;
}
if (!includeSkipped) {
where.skipped = false;
}
if (!includeResolved) {
where.resolved = false;
}
const [failures, total] = await Promise.all([
prisma.enrichmentFailure.findMany({
where,
orderBy: { lastFailedAt: "desc" },
take: limit,
skip: offset,
}),
prisma.enrichmentFailure.count({ where }),
]);
return { failures: failures as unknown as EnrichmentFailure[], total };
}
/**
* Get failure counts by type
*/
async getFailureCounts(): Promise<{
artist: number;
track: number;
audio: number;
total: number;
}> {
const [artistCount, trackCount, audioCount] = await Promise.all([
prisma.enrichmentFailure.count({
where: {
entityType: "artist",
resolved: false,
skipped: false,
},
}),
prisma.enrichmentFailure.count({
where: { entityType: "track", resolved: false, skipped: false },
}),
prisma.enrichmentFailure.count({
where: { entityType: "audio", resolved: false, skipped: false },
}),
]);
return {
artist: artistCount,
track: trackCount,
audio: audioCount,
total: artistCount + trackCount + audioCount,
};
}
/**
* Get a single failure by ID
*/
async getFailure(id: string): Promise<EnrichmentFailure | null> {
return await prisma.enrichmentFailure.findUnique({
where: { id },
}) as unknown as EnrichmentFailure | null;
}
/**
* Mark failures as skipped (won't be retried automatically)
*/
async skipFailures(ids: string[]): Promise<number> {
const result = await prisma.enrichmentFailure.updateMany({
where: { id: { in: ids } },
data: {
skipped: true,
skippedAt: new Date(),
},
});
return result.count;
}
/**
* Mark failures as resolved (manually fixed)
*/
async resolveFailures(ids: string[]): Promise<number> {
const result = await prisma.enrichmentFailure.updateMany({
where: { id: { in: ids } },
data: {
resolved: true,
resolvedAt: new Date(),
},
});
return result.count;
}
/**
* Reset retry count for failures (prepare for retry)
*/
async resetRetryCount(ids: string[]): Promise<number> {
const result = await prisma.enrichmentFailure.updateMany({
where: { id: { in: ids } },
data: {
retryCount: 0,
},
});
return result.count;
}
/**
* Delete failures (cleanup resolved/old failures)
*/
async deleteFailures(ids: string[]): Promise<number> {
const result = await prisma.enrichmentFailure.deleteMany({
where: { id: { in: ids } },
});
return result.count;
}
/**
* Cleanup old resolved failures (older than specified days)
*/
async cleanupOldResolved(olderThanDays: number = 30): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
const result = await prisma.enrichmentFailure.deleteMany({
where: {
resolved: true,
resolvedAt: {
lt: cutoffDate,
},
},
});
logger.debug(
`[Enrichment Failures] Cleaned up ${result.count} old resolved failures`
);
return result.count;
}
/**
* Check if an entity has failed too many times
*/
async hasExceededRetries(
entityType: string,
entityId: string
): Promise<boolean> {
const failure = await prisma.enrichmentFailure.findUnique({
where: {
entityType_entityId: {
entityType: entityType as any,
entityId,
},
},
});
if (!failure) return false;
return failure.retryCount >= failure.maxRetries;
}
/**
* Clear failure record (reset for fresh retry)
*/
async clearFailure(entityType: string, entityId: string): Promise<void> {
await prisma.enrichmentFailure.deleteMany({
where: {
entityType: entityType as any,
entityId,
},
});
}
/**
* Clean up failures for entities that no longer exist in the database.
* This resolves orphaned failure records where the track/artist was deleted.
*/
async cleanupOrphanedFailures(): Promise<{
cleaned: number;
checked: number;
}> {
// Get all unresolved failures
const failures = await prisma.enrichmentFailure.findMany({
where: { resolved: false, skipped: false },
select: { id: true, entityType: true, entityId: true },
});
const toResolve: string[] = [];
for (const failure of failures) {
let exists = false;
if (failure.entityType === "artist") {
const artist = await prisma.artist.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
exists = !!artist;
} else if (
failure.entityType === "track" ||
failure.entityType === "audio"
) {
const track = await prisma.track.findUnique({
where: { id: failure.entityId },
select: { id: true },
});
exists = !!track;
}
if (!exists) {
toResolve.push(failure.id);
}
}
if (toResolve.length > 0) {
await this.resolveFailures(toResolve);
logger.debug(
`[Enrichment Failures] Cleaned up ${toResolve.length} orphaned failures`
);
}
return { cleaned: toResolve.length, checked: failures.length };
}
}
// Singleton instance
export const enrichmentFailureService = new EnrichmentFailureService();
+267
View File
@@ -0,0 +1,267 @@
/**
* Enrichment State Management Service
*
* Manages the state of enrichment processes using Redis for cross-process coordination.
* Allows pause/resume/stop controls and tracks current progress.
*/
import { logger } from "../utils/logger";
import Redis from "ioredis";
import { config } from "../config";
const ENRICHMENT_STATE_KEY = "enrichment:state";
const ENRICHMENT_CONTROL_CHANNEL = "enrichment:control";
const AUDIO_CONTROL_CHANNEL = "audio:analysis:control";
export type EnrichmentStatus = "idle" | "running" | "paused" | "stopping";
export type EnrichmentPhase = "artists" | "tracks" | "audio" | null;
export interface EnrichmentState {
status: EnrichmentStatus;
startedAt?: string;
pausedAt?: string;
stoppedAt?: string;
currentPhase: EnrichmentPhase;
lastActivity: string;
completionNotificationSent?: boolean; // Prevent repeated completion notifications
stoppingInfo?: {
phase: string;
currentItem: string;
itemsRemaining: number;
};
// Progress tracking
artists: {
total: number;
completed: number;
failed: number;
current?: string; // Currently processing artist name
};
tracks: {
total: number;
completed: number;
failed: number;
current?: string; // Currently processing track
};
audio: {
total: number;
completed: number;
failed: number;
processing: number; // Currently in worker pool
};
}
class EnrichmentStateService {
private redis: Redis;
private publisher: Redis;
constructor() {
this.redis = new Redis(config.redisUrl);
this.publisher = new Redis(config.redisUrl);
}
/**
* Get current enrichment state
*/
async getState(): Promise<EnrichmentState | null> {
const data = await this.redis.get(ENRICHMENT_STATE_KEY);
if (!data) {
return null;
}
return JSON.parse(data);
}
/**
* Initialize enrichment state
*/
async initializeState(): Promise<EnrichmentState> {
const state: EnrichmentState = {
status: "running",
startedAt: new Date().toISOString(),
currentPhase: "artists",
lastActivity: new Date().toISOString(),
completionNotificationSent: false, // Reset notification flag on new enrichment
artists: { total: 0, completed: 0, failed: 0 },
tracks: { total: 0, completed: 0, failed: 0 },
audio: { total: 0, completed: 0, failed: 0, processing: 0 },
};
await this.setState(state);
return state;
}
/**
* Update enrichment state
*/
async setState(state: EnrichmentState): Promise<void> {
state.lastActivity = new Date().toISOString();
await this.redis.set(ENRICHMENT_STATE_KEY, JSON.stringify(state));
}
/**
* Update specific fields in state
* Auto-initializes state if it doesn't exist
*/
async updateState(
updates: Partial<EnrichmentState>
): Promise<EnrichmentState> {
let current = await this.getState();
// Auto-initialize if state doesn't exist
if (!current) {
logger.debug("[Enrichment State] State not found, initializing...");
current = await this.initializeState();
}
const updated = { ...current, ...updates };
await this.setState(updated);
return updated;
}
/**
* Pause enrichment process
*/
async pause(): Promise<EnrichmentState> {
const state = await this.getState();
if (!state) {
throw new Error("No active enrichment to pause");
}
if (state.status !== "running") {
throw new Error(`Cannot pause enrichment in ${state.status} state`);
}
const updated = await this.updateState({
status: "paused",
pausedAt: new Date().toISOString(),
});
// Notify workers via pub/sub
await this.publisher.publish(ENRICHMENT_CONTROL_CHANNEL, "pause");
await this.publisher.publish(AUDIO_CONTROL_CHANNEL, "pause");
logger.debug("[Enrichment State] Paused");
return updated;
}
/**
* Resume enrichment process
*/
async resume(): Promise<EnrichmentState> {
const state = await this.getState();
if (!state) {
throw new Error("No enrichment state to resume");
}
// Idempotent: If already running, return success
if (state.status === "running") {
logger.debug("[Enrichment State] Already running");
return state;
}
if (state.status !== "paused") {
throw new Error(
`Cannot resume enrichment in ${state.status} state`
);
}
const updated = await this.updateState({
status: "running",
pausedAt: undefined,
});
// Notify workers via pub/sub
await this.publisher.publish(ENRICHMENT_CONTROL_CHANNEL, "resume");
await this.publisher.publish(AUDIO_CONTROL_CHANNEL, "resume");
logger.debug("[Enrichment State] Resumed");
return updated;
}
/**
* Stop enrichment process
*/
async stop(): Promise<EnrichmentState> {
const state = await this.getState();
if (!state) {
throw new Error("No active enrichment to stop");
}
// Idempotent: If already idle, return success
if (state.status === "idle") {
logger.debug("[Enrichment State] Already stopped (idle)");
return state;
}
const updated = await this.updateState({
status: "stopping",
stoppedAt: new Date().toISOString(),
});
// Notify workers via pub/sub
await this.publisher.publish(ENRICHMENT_CONTROL_CHANNEL, "stop");
await this.publisher.publish(AUDIO_CONTROL_CHANNEL, "stop");
logger.debug("[Enrichment State] Stopping...");
// Transition to idle after a delay (workers will clean up)
setTimeout(async () => {
await this.updateState({ status: "idle", currentPhase: null });
logger.debug("[Enrichment State] Stopped and idle");
}, 5000);
return updated;
}
/**
* Clear enrichment state (set to idle)
*/
async clear(): Promise<void> {
await this.redis.del(ENRICHMENT_STATE_KEY);
logger.debug("[Enrichment State] Cleared");
}
/**
* Check if enrichment is currently running
*/
async isRunning(): Promise<boolean> {
const state = await this.getState();
return state?.status === "running";
}
/**
* Check if enrichment is paused
*/
async isPaused(): Promise<boolean> {
const state = await this.getState();
return state?.status === "paused";
}
/**
* Check for hung processes (no activity for > 15 minutes)
*/
async detectHang(): Promise<boolean> {
const state = await this.getState();
if (!state || state.status !== "running") {
return false;
}
const lastActivity = new Date(state.lastActivity);
const now = new Date();
const minutesSinceActivity =
(now.getTime() - lastActivity.getTime()) / (1000 * 60);
return minutesSinceActivity > 15;
}
/**
* Cleanup connections
*/
async disconnect(): Promise<void> {
await this.redis.quit();
await this.publisher.quit();
}
}
// Singleton instance
export const enrichmentStateService = new EnrichmentStateService();
+13 -12
View File
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { logger } from "../utils/logger";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { getSystemSettings } from "../utils/systemSettings"; import { getSystemSettings } from "../utils/systemSettings";
@@ -38,7 +39,7 @@ class FanartService {
const settings = await getSystemSettings(); const settings = await getSystemSettings();
if (settings?.fanartEnabled && settings?.fanartApiKey) { if (settings?.fanartEnabled && settings?.fanartApiKey) {
this.apiKey = settings.fanartApiKey; this.apiKey = settings.fanartApiKey;
console.log("Fanart.tv configured from database"); logger.debug("Fanart.tv configured from database");
this.initialized = true; this.initialized = true;
return; return;
} }
@@ -49,7 +50,7 @@ class FanartService {
// Fallback to .env // Fallback to .env
if (process.env.FANART_API_KEY) { if (process.env.FANART_API_KEY) {
this.apiKey = process.env.FANART_API_KEY; this.apiKey = process.env.FANART_API_KEY;
console.log("Fanart.tv configured from .env"); logger.debug("Fanart.tv configured from .env");
} }
// Note: Not logging "not configured" here - it's optional and logs are spammy // Note: Not logging "not configured" here - it's optional and logs are spammy
this.initialized = true; this.initialized = true;
@@ -73,7 +74,7 @@ class FanartService {
if (redisClient.isOpen) { if (redisClient.isOpen) {
const cached = await redisClient.get(cacheKey); const cached = await redisClient.get(cacheKey);
if (cached) { if (cached) {
console.log(` Fanart.tv: Using cached image`); logger.debug(` Fanart.tv: Using cached image`);
return cached; return cached;
} }
} }
@@ -82,7 +83,7 @@ class FanartService {
} }
try { try {
console.log(` Fetching from Fanart.tv...`); logger.debug(` Fetching from Fanart.tv...`);
const response = await this.client.get(`/music/${mbid}`, { const response = await this.client.get(`/music/${mbid}`, {
params: { api_key: this.apiKey }, params: { api_key: this.apiKey },
}); });
@@ -98,39 +99,39 @@ class FanartService {
// If it's just a filename, construct the full URL // If it's just a filename, construct the full URL
if (rawUrl && !rawUrl.startsWith("http")) { if (rawUrl && !rawUrl.startsWith("http")) {
rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistbackground/${rawUrl}`; rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistbackground/${rawUrl}`;
console.log( logger.debug(
` Fanart.tv: Constructed full URL from filename` ` Fanart.tv: Constructed full URL from filename`
); );
} }
imageUrl = rawUrl; imageUrl = rawUrl;
console.log(` Fanart.tv: Found artist background`); logger.debug(` Fanart.tv: Found artist background`);
} else if (data.artistthumb && data.artistthumb.length > 0) { } else if (data.artistthumb && data.artistthumb.length > 0) {
let rawUrl = data.artistthumb[0].url; let rawUrl = data.artistthumb[0].url;
// If it's just a filename, construct the full URL // If it's just a filename, construct the full URL
if (rawUrl && !rawUrl.startsWith("http")) { if (rawUrl && !rawUrl.startsWith("http")) {
rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistthumb/${rawUrl}`; rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistthumb/${rawUrl}`;
console.log( logger.debug(
` Fanart.tv: Constructed full URL from filename` ` Fanart.tv: Constructed full URL from filename`
); );
} }
imageUrl = rawUrl; imageUrl = rawUrl;
console.log(` Fanart.tv: Found artist thumbnail`); logger.debug(` Fanart.tv: Found artist thumbnail`);
} else if (data.hdmusiclogo && data.hdmusiclogo.length > 0) { } else if (data.hdmusiclogo && data.hdmusiclogo.length > 0) {
let rawUrl = data.hdmusiclogo[0].url; let rawUrl = data.hdmusiclogo[0].url;
// If it's just a filename, construct the full URL // If it's just a filename, construct the full URL
if (rawUrl && !rawUrl.startsWith("http")) { if (rawUrl && !rawUrl.startsWith("http")) {
rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/hdmusiclogo/${rawUrl}`; rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/hdmusiclogo/${rawUrl}`;
console.log( logger.debug(
` Fanart.tv: Constructed full URL from filename` ` Fanart.tv: Constructed full URL from filename`
); );
} }
imageUrl = rawUrl; imageUrl = rawUrl;
console.log(` Fanart.tv: Found HD logo`); logger.debug(` Fanart.tv: Found HD logo`);
} }
// Cache for 7 days // Cache for 7 days
@@ -149,9 +150,9 @@ class FanartService {
return imageUrl; return imageUrl;
} catch (error: any) { } catch (error: any) {
if (error.response?.status === 404) { if (error.response?.status === 404) {
console.log(`Fanart.tv: No images found`); logger.debug(`Fanart.tv: No images found`);
} else { } else {
console.error(` Fanart.tv error:`, error.message); logger.error(` Fanart.tv error:`, error.message);
} }
return null; return null;
} }
+11 -10
View File
@@ -1,4 +1,5 @@
import * as fs from "fs"; import * as fs from "fs";
import { logger } from "../utils/logger";
import * as path from "path"; import * as path from "path";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { config } from "../config"; import { config } from "../config";
@@ -26,7 +27,7 @@ export class FileValidatorService {
duration: 0, duration: 0,
}; };
console.log("[FileValidator] Starting library validation..."); logger.debug("[FileValidator] Starting library validation...");
// Get all tracks from the database // Get all tracks from the database
const tracks = await prisma.track.findMany({ const tracks = await prisma.track.findMany({
@@ -37,7 +38,7 @@ export class FileValidatorService {
}, },
}); });
console.log( logger.debug(
`[FileValidator] Found ${tracks.length} tracks to validate` `[FileValidator] Found ${tracks.length} tracks to validate`
); );
@@ -53,7 +54,7 @@ export class FileValidatorService {
// Prevent path traversal attacks // Prevent path traversal attacks
if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) { if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) {
console.warn( logger.warn(
`[FileValidator] Path traversal attempt detected: ${track.filePath}` `[FileValidator] Path traversal attempt detected: ${track.filePath}`
); );
missingTrackIds.push(track.id); missingTrackIds.push(track.id);
@@ -64,7 +65,7 @@ export class FileValidatorService {
const exists = await this.fileExists(absolutePath); const exists = await this.fileExists(absolutePath);
if (!exists) { if (!exists) {
console.log( logger.debug(
`[FileValidator] Missing file: ${track.filePath} (${track.title})` `[FileValidator] Missing file: ${track.filePath} (${track.title})`
); );
missingTrackIds.push(track.id); missingTrackIds.push(track.id);
@@ -74,12 +75,12 @@ export class FileValidatorService {
// Log progress every 100 tracks // Log progress every 100 tracks
if (result.tracksChecked % 100 === 0) { if (result.tracksChecked % 100 === 0) {
console.log( logger.debug(
`[FileValidator] Progress: ${result.tracksChecked}/${tracks.length} tracks checked, ${missingTrackIds.length} missing` `[FileValidator] Progress: ${result.tracksChecked}/${tracks.length} tracks checked, ${missingTrackIds.length} missing`
); );
} }
} catch (err: any) { } catch (err: any) {
console.error( logger.error(
`[FileValidator] Error checking ${track.filePath}:`, `[FileValidator] Error checking ${track.filePath}:`,
err.message err.message
); );
@@ -93,7 +94,7 @@ export class FileValidatorService {
// Remove missing tracks from database // Remove missing tracks from database
if (missingTrackIds.length > 0) { if (missingTrackIds.length > 0) {
console.log( logger.debug(
`[FileValidator] Removing ${missingTrackIds.length} missing tracks from database...` `[FileValidator] Removing ${missingTrackIds.length} missing tracks from database...`
); );
@@ -108,7 +109,7 @@ export class FileValidatorService {
result.duration = Date.now() - startTime; result.duration = Date.now() - startTime;
console.log( logger.debug(
`[FileValidator] Validation complete: ${result.tracksChecked} checked, ${result.tracksRemoved} removed (${result.duration}ms)` `[FileValidator] Validation complete: ${result.tracksChecked} checked, ${result.tracksRemoved} removed (${result.duration}ms)`
); );
@@ -150,7 +151,7 @@ export class FileValidatorService {
// Prevent path traversal attacks // Prevent path traversal attacks
if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) { if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) {
console.warn( logger.warn(
`[FileValidator] Path traversal attempt detected: ${track.filePath}` `[FileValidator] Path traversal attempt detected: ${track.filePath}`
); );
return false; return false;
@@ -159,7 +160,7 @@ export class FileValidatorService {
const exists = await this.fileExists(absolutePath); const exists = await this.fileExists(absolutePath);
if (!exists) { if (!exists) {
console.log( logger.debug(
`[FileValidator] Track file missing, removing from DB: ${track.title}` `[FileValidator] Track file missing, removing from DB: ${track.title}`
); );
await prisma.track.delete({ await prisma.track.delete({
+18 -17
View File
@@ -8,6 +8,7 @@
* 4. Last.fm (fallback, often missing) * 4. Last.fm (fallback, often missing)
*/ */
import { logger } from "../utils/logger";
import axios from "axios"; import axios from "axios";
export interface ImageSearchOptions { export interface ImageSearchOptions {
@@ -36,7 +37,7 @@ export class ImageProviderService {
): Promise<ImageResult | null> { ): Promise<ImageResult | null> {
const { timeout = 5000 } = options; const { timeout = 5000 } = options;
console.log(`[IMAGE] Searching for artist image: ${artistName}`); logger.debug(`[IMAGE] Searching for artist image: ${artistName}`);
// Try Deezer first (most reliable) // Try Deezer first (most reliable)
try { try {
@@ -45,11 +46,11 @@ export class ImageProviderService {
timeout timeout
); );
if (deezerImage) { if (deezerImage) {
console.log(` Found image from Deezer`); logger.debug(` Found image from Deezer`);
return deezerImage; return deezerImage;
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
` Deezer failed: ${ ` Deezer failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
@@ -64,11 +65,11 @@ export class ImageProviderService {
timeout timeout
); );
if (fanartImage) { if (fanartImage) {
console.log(` Found image from Fanart.tv`); logger.debug(` Found image from Fanart.tv`);
return fanartImage; return fanartImage;
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
`Fanart.tv failed: ${ `Fanart.tv failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
@@ -84,11 +85,11 @@ export class ImageProviderService {
timeout timeout
); );
if (mbImage) { if (mbImage) {
console.log(` Found image from MusicBrainz`); logger.debug(` Found image from MusicBrainz`);
return mbImage; return mbImage;
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
`MusicBrainz failed: ${ `MusicBrainz failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
@@ -96,7 +97,7 @@ export class ImageProviderService {
} }
} }
console.log(` No artist image found from any source`); logger.debug(` No artist image found from any source`);
return null; return null;
} }
@@ -111,7 +112,7 @@ export class ImageProviderService {
): Promise<ImageResult | null> { ): Promise<ImageResult | null> {
const { timeout = 5000 } = options; const { timeout = 5000 } = options;
console.log( logger.debug(
`[IMAGE] Searching for album cover: ${artistName} - ${albumTitle}` `[IMAGE] Searching for album cover: ${artistName} - ${albumTitle}`
); );
@@ -123,11 +124,11 @@ export class ImageProviderService {
timeout timeout
); );
if (deezerCover) { if (deezerCover) {
console.log(` Found cover from Deezer`); logger.debug(` Found cover from Deezer`);
return deezerCover; return deezerCover;
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
` Deezer failed: ${ ` Deezer failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
@@ -142,11 +143,11 @@ export class ImageProviderService {
timeout timeout
); );
if (mbCover) { if (mbCover) {
console.log(` Found cover from MusicBrainz`); logger.debug(` Found cover from MusicBrainz`);
return mbCover; return mbCover;
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
`MusicBrainz failed: ${ `MusicBrainz failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
@@ -162,11 +163,11 @@ export class ImageProviderService {
timeout timeout
); );
if (fanartCover) { if (fanartCover) {
console.log(` Found cover from Fanart.tv`); logger.debug(` Found cover from Fanart.tv`);
return fanartCover; return fanartCover;
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
`Fanart.tv failed: ${ `Fanart.tv failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
@@ -174,7 +175,7 @@ export class ImageProviderService {
} }
} }
console.log(` No album cover found from any source`); logger.debug(` No album cover found from any source`);
return null; return null;
} }
@@ -407,7 +408,7 @@ export class ImageProviderService {
} }
} }
} catch (error) { } catch (error) {
console.log( logger.debug(
`Last.fm failed: ${ `Last.fm failed: ${
error instanceof Error ? error.message : "Unknown error" error instanceof Error ? error.message : "Unknown error"
}` }`
+16 -15
View File
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { logger } from "../utils/logger";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
interface ItunesPodcast { interface ItunesPodcast {
@@ -51,7 +52,7 @@ class ItunesService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
await this.rateLimit(); await this.rateLimit();
@@ -60,7 +61,7 @@ class ItunesService {
try { try {
await redisClient.setEx(cacheKey, ttlSeconds, JSON.stringify(data)); await redisClient.setEx(cacheKey, ttlSeconds, JSON.stringify(data));
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return data; return data;
@@ -234,13 +235,13 @@ class ItunesService {
const keywords = this.extractSearchKeywords(title, description, author); const keywords = this.extractSearchKeywords(title, description, author);
if (keywords.length === 0) { if (keywords.length === 0) {
console.log( logger.debug(
"No keywords extracted for similar podcast search, falling back to title" "No keywords extracted for similar podcast search, falling back to title"
); );
return this.searchPodcasts(title, limit); return this.searchPodcasts(title, limit);
} }
console.log( logger.debug(
` Searching for similar podcasts using keywords: ${keywords.join(", ")}` ` Searching for similar podcasts using keywords: ${keywords.join(", ")}`
); );
@@ -275,31 +276,31 @@ class ItunesService {
genreId: number, genreId: number,
limit = 20 limit = 20
): Promise<ItunesPodcast[]> { ): Promise<ItunesPodcast[]> {
console.log(`[iTunes SERVICE] getTopPodcastsByGenre called with genre=${genreId}, limit=${limit}`); logger.debug(`[iTunes SERVICE] getTopPodcastsByGenre called with genre=${genreId}, limit=${limit}`);
const cacheKey = `itunes:genre:${genreId}:${limit}`; const cacheKey = `itunes:genre:${genreId}:${limit}`;
console.log(`[iTunes SERVICE] Cache key: ${cacheKey}`); logger.debug(`[iTunes SERVICE] Cache key: ${cacheKey}`);
const result = await this.cachedRequest( const result = await this.cachedRequest(
cacheKey, cacheKey,
async () => { async () => {
try { try {
console.log(`[iTunes] Fetching genre ${genreId} from RSS feed...`); logger.debug(`[iTunes] Fetching genre ${genreId} from RSS feed...`);
// Use iTunes RSS feed for top podcasts by genre // Use iTunes RSS feed for top podcasts by genre
const response = await this.client.get( const response = await this.client.get(
`/us/rss/toppodcasts/genre=${genreId}/limit=${limit}/json` `/us/rss/toppodcasts/genre=${genreId}/limit=${limit}/json`
); );
console.log(`[iTunes] Response status: ${response.status}`); logger.debug(`[iTunes] Response status: ${response.status}`);
console.log(`[iTunes] Has feed data: ${!!response.data?.feed}`); logger.debug(`[iTunes] Has feed data: ${!!response.data?.feed}`);
console.log(`[iTunes] Entries count: ${response.data?.feed?.entry?.length || 0}`); logger.debug(`[iTunes] Entries count: ${response.data?.feed?.entry?.length || 0}`);
const entries = response.data?.feed?.entry || []; const entries = response.data?.feed?.entry || [];
// If only one entry, it might not be an array // If only one entry, it might not be an array
const entriesArray = Array.isArray(entries) ? entries : [entries]; const entriesArray = Array.isArray(entries) ? entries : [entries];
console.log(`[iTunes] Processing ${entriesArray.length} entries`); logger.debug(`[iTunes] Processing ${entriesArray.length} entries`);
// Convert RSS feed format to our podcast format // Convert RSS feed format to our podcast format
const podcasts = entriesArray.map((entry: any) => { const podcasts = entriesArray.map((entry: any) => {
@@ -315,21 +316,21 @@ class ItunesService {
primaryGenreName: entry.category?.attributes?.label, primaryGenreName: entry.category?.attributes?.label,
collectionViewUrl: entry.link?.attributes?.href, collectionViewUrl: entry.link?.attributes?.href,
}; };
console.log(`[iTunes] Mapped podcast: ${podcast.collectionName} (ID: ${podcast.collectionId})`); logger.debug(`[iTunes] Mapped podcast: ${podcast.collectionName} (ID: ${podcast.collectionId})`);
return podcast; return podcast;
}).filter((p: any) => p.collectionId > 0); // Filter out invalid entries }).filter((p: any) => p.collectionId > 0); // Filter out invalid entries
console.log(`[iTunes] Returning ${podcasts.length} valid podcasts`); logger.debug(`[iTunes] Returning ${podcasts.length} valid podcasts`);
return podcasts; return podcasts;
} catch (error) { } catch (error) {
console.error(`[iTunes] ERROR in requestFn:`, error); logger.error(`[iTunes] ERROR in requestFn:`, error);
return []; return [];
} }
}, },
2592000 // 30 days 2592000 // 30 days
); );
console.log(`[iTunes SERVICE] cachedRequest returned ${result.length} podcasts`); logger.debug(`[iTunes SERVICE] cachedRequest returned ${result.length} podcasts`);
return result; return result;
} }
} }
+183 -52
View File
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { logger } from "../utils/logger";
import * as fuzz from "fuzzball"; import * as fuzz from "fuzzball";
import { config } from "../config"; import { config } from "../config";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
@@ -6,6 +7,7 @@ import { getSystemSettings } from "../utils/systemSettings";
import { fanartService } from "./fanart"; import { fanartService } from "./fanart";
import { deezerService } from "./deezer"; import { deezerService } from "./deezer";
import { rateLimiter } from "./rateLimiter"; import { rateLimiter } from "./rateLimiter";
import { normalizeToArray } from "../utils/normalize";
interface SimilarArtist { interface SimilarArtist {
name: string; name: string;
@@ -39,24 +41,34 @@ class LastFmService {
const settings = await getSystemSettings(); const settings = await getSystemSettings();
if (settings?.lastfmApiKey) { if (settings?.lastfmApiKey) {
this.apiKey = settings.lastfmApiKey; this.apiKey = settings.lastfmApiKey;
console.log("Last.fm configured from user settings"); logger.debug("Last.fm configured from user settings");
} else if (this.apiKey) { } else if (this.apiKey) {
console.log("Last.fm configured (default app key)"); logger.debug("Last.fm configured (default app key)");
} }
} catch (err) { } catch (err) {
// DB not ready yet, use default/env key // DB not ready yet, use default/env key
if (this.apiKey) { if (this.apiKey) {
console.log("Last.fm configured (default app key)"); logger.debug("Last.fm configured (default app key)");
} }
} }
if (!this.apiKey) { if (!this.apiKey) {
console.warn("Last.fm API key not available"); logger.warn("Last.fm API key not available");
} }
this.initialized = true; this.initialized = true;
} }
/**
* Refresh the API key from current settings
* Called when system settings are updated to pick up new key
*/
async refreshApiKey(): Promise<void> {
this.initialized = false;
await this.ensureInitialized();
logger.debug("Last.fm API key refreshed from settings");
}
private async request<T = any>(params: Record<string, any>) { private async request<T = any>(params: Record<string, any>) {
await this.ensureInitialized(); await this.ensureInitialized();
const response = await rateLimiter.execute("lastfm", () => const response = await rateLimiter.execute("lastfm", () =>
@@ -78,7 +90,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -107,7 +119,7 @@ class LastFmService {
JSON.stringify(results) JSON.stringify(results)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return results; return results;
@@ -117,13 +129,13 @@ class LastFmService {
error.response?.status === 404 || error.response?.status === 404 ||
error.response?.data?.error === 6 error.response?.data?.error === 6
) { ) {
console.log( logger.debug(
`Artist MBID not found on Last.fm, trying name search: ${artistName}` `Artist MBID not found on Last.fm, trying name search: ${artistName}`
); );
return this.getSimilarArtistsByName(artistName, limit); return this.getSimilarArtistsByName(artistName, limit);
} }
console.error(`Last.fm error for ${artistName}:`, error); logger.error(`Last.fm error for ${artistName}:`, error);
return []; return [];
} }
} }
@@ -140,7 +152,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -169,12 +181,12 @@ class LastFmService {
JSON.stringify(results) JSON.stringify(results)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return results; return results;
} catch (error) { } catch (error) {
console.error(`Last.fm error for ${artistName}:`, error); logger.error(`Last.fm error for ${artistName}:`, error);
return []; return [];
} }
} }
@@ -188,7 +200,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -202,20 +214,38 @@ class LastFmService {
const album = data.album; const album = data.album;
// Cache for 30 days // Normalize arrays before caching/returning
try { if (album) {
await redisClient.setEx( const normalized = {
cacheKey, ...album,
2592000, image: normalizeToArray(album.image),
JSON.stringify(album) tags: album.tags ? {
); ...album.tags,
} catch (err) { tag: normalizeToArray(album.tags.tag)
console.warn("Redis set error:", err); } : album.tags,
tracks: album.tracks ? {
...album.tracks,
track: normalizeToArray(album.tracks.track)
} : album.tracks
};
// Cache for 30 days
try {
await redisClient.setEx(
cacheKey,
2592000,
JSON.stringify(normalized)
);
} catch (err) {
logger.warn("Redis set error:", err);
}
return normalized;
} }
return album; return album;
} catch (error) { } catch (error) {
console.error(`Last.fm album info error for ${albumName}:`, error); logger.error(`Last.fm album info error for ${albumName}:`, error);
return null; return null;
} }
} }
@@ -229,7 +259,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -251,12 +281,12 @@ class LastFmService {
JSON.stringify(albums) JSON.stringify(albums)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return albums; return albums;
} catch (error) { } catch (error) {
console.error(`Last.fm tag albums error for ${tag}:`, error); logger.error(`Last.fm tag albums error for ${tag}:`, error);
return []; return [];
} }
} }
@@ -270,7 +300,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -293,12 +323,12 @@ class LastFmService {
JSON.stringify(tracks) JSON.stringify(tracks)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return tracks; return tracks;
} catch (error) { } catch (error) {
console.error( logger.error(
`Last.fm similar tracks error for ${trackName}:`, `Last.fm similar tracks error for ${trackName}:`,
error error
); );
@@ -319,7 +349,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -348,12 +378,12 @@ class LastFmService {
JSON.stringify(tracks) JSON.stringify(tracks)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return tracks; return tracks;
} catch (error) { } catch (error) {
console.error(`Last.fm top tracks error for ${artistName}:`, error); logger.error(`Last.fm top tracks error for ${artistName}:`, error);
return []; return [];
} }
} }
@@ -371,7 +401,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -400,12 +430,12 @@ class LastFmService {
JSON.stringify(albums) JSON.stringify(albums)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return albums; return albums;
} catch (error) { } catch (error) {
console.error(`Last.fm top albums error for ${artistName}:`, error); logger.error(`Last.fm top albums error for ${artistName}:`, error);
return []; return [];
} }
} }
@@ -428,9 +458,27 @@ class LastFmService {
} }
const data = await this.request(params); const data = await this.request(params);
return data.artist; const artist = data.artist;
// Normalize arrays before returning
if (artist) {
return {
...artist,
image: normalizeToArray(artist.image),
tags: artist.tags ? {
...artist.tags,
tag: normalizeToArray(artist.tags.tag)
} : artist.tags,
similar: artist.similar ? {
...artist.similar,
artist: normalizeToArray(artist.similar.artist)
} : artist.similar
};
}
return artist;
} catch (error) { } catch (error) {
console.error( logger.error(
`Last.fm artist info error for ${artistName}:`, `Last.fm artist info error for ${artistName}:`,
error error
); );
@@ -538,7 +586,7 @@ class LastFmService {
name: artist.name, name: artist.name,
listeners: parseInt(artist.listeners || "0", 10), listeners: parseInt(artist.listeners || "0", 10),
url: artist.url, url: artist.url,
image: this.getBestImage(artist.image), image: this.getBestImage(normalizeToArray(artist.image)),
mbid: artist.mbid, mbid: artist.mbid,
bio: null, bio: null,
tags: [] as string[], tags: [] as string[],
@@ -587,7 +635,7 @@ class LastFmService {
album: track.album || null, album: track.album || null,
listeners: parseInt(track.listeners || "0", 10), listeners: parseInt(track.listeners || "0", 10),
url: track.url, url: track.url,
image: this.getBestImage(track.image), image: this.getBestImage(normalizeToArray(track.image)),
mbid: track.mbid, mbid: track.mbid,
}; };
@@ -633,7 +681,7 @@ class LastFmService {
const artists = data.results?.artistmatches?.artist || []; const artists = data.results?.artistmatches?.artist || [];
console.log( logger.debug(
`\n [LAST.FM SEARCH] Found ${artists.length} artists (before filtering)` `\n [LAST.FM SEARCH] Found ${artists.length} artists (before filtering)`
); );
@@ -675,11 +723,11 @@ class LastFmService {
wordMatches, wordMatches,
}; };
}) })
.filter(({ similarity, wordMatches }) => { .filter(({ similarity, wordMatches }: { similarity: number; wordMatches: number }) => {
if (!queryLower) return true; if (!queryLower) return true;
return similarity >= 50 || wordMatches >= minWordMatches; return similarity >= 50 || wordMatches >= minWordMatches;
}) })
.sort((a, b) => { .sort((a: any, b: any) => {
return ( return (
Number(b.hasMbid) - Number(a.hasMbid) || Number(b.hasMbid) - Number(a.hasMbid) ||
b.wordMatches - a.wordMatches || b.wordMatches - a.wordMatches ||
@@ -728,7 +776,7 @@ class LastFmService {
uniqueArtists.push(candidate); uniqueArtists.push(candidate);
} }
} catch (error) { } catch (error) {
console.warn( logger.warn(
"[LAST.FM SEARCH] Similar artist fallback failed:", "[LAST.FM SEARCH] Similar artist fallback failed:",
error error
); );
@@ -737,7 +785,7 @@ class LastFmService {
const limitedArtists = uniqueArtists.slice(0, limit); const limitedArtists = uniqueArtists.slice(0, limit);
console.log( logger.debug(
` → Filtered to ${limitedArtists.length} relevant matches (limit: ${limit})` ` → Filtered to ${limitedArtists.length} relevant matches (limit: ${limit})`
); );
@@ -761,7 +809,7 @@ class LastFmService {
return [...enriched, ...fast].filter(Boolean); return [...enriched, ...fast].filter(Boolean);
} catch (error) { } catch (error) {
console.error("Last.fm artist search error:", error); logger.error("Last.fm artist search error:", error);
return []; return [];
} }
} }
@@ -781,7 +829,7 @@ class LastFmService {
const tracks = data.results?.trackmatches?.track || []; const tracks = data.results?.trackmatches?.track || [];
console.log( logger.debug(
`\n [LAST.FM TRACK SEARCH] Found ${tracks.length} tracks` `\n [LAST.FM TRACK SEARCH] Found ${tracks.length} tracks`
); );
@@ -811,7 +859,7 @@ class LastFmService {
return [...enriched, ...fast].filter(Boolean); return [...enriched, ...fast].filter(Boolean);
} catch (error) { } catch (error) {
console.error("Last.fm track search error:", error); logger.error("Last.fm track search error:", error);
return []; return [];
} }
} }
@@ -829,13 +877,96 @@ class LastFmService {
format: "json", format: "json",
}); });
return data.track; const track = data.track;
// Normalize arrays before returning
if (track) {
return {
...track,
toptags: track.toptags ? {
...track.toptags,
tag: normalizeToArray(track.toptags.tag)
} : track.toptags,
album: track.album ? {
...track.album,
image: normalizeToArray(track.album.image)
} : track.album
};
}
return track;
} catch (error) { } catch (error) {
// Don't log errors for track info (many tracks don't have full info) // Don't log errors for track info (many tracks don't have full info)
return null; return null;
} }
} }
/**
* Get the canonical artist name from Last.fm correction API
* Resolves aliases and misspellings to official artist names
*
* @param artistName - The artist name to check for corrections
* @returns The canonical artist name info, or null if no correction found
*
* @example
* getArtistCorrection("of mice") // Returns { corrected: true, canonicalName: "Of Mice & Men", mbid: "..." }
* getArtistCorrection("bjork") // Returns { corrected: true, canonicalName: "Björk", mbid: "..." }
*/
async getArtistCorrection(artistName: string): Promise<{
corrected: boolean;
canonicalName: string;
mbid?: string;
} | null> {
const cacheKey = `lastfm:correction:${artistName.toLowerCase().trim()}`;
// Check cache first (30-day TTL)
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return cached === "null" ? null : JSON.parse(cached);
}
} catch (err) {
logger.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "artist.getCorrection",
artist: artistName,
api_key: this.apiKey,
format: "json",
});
const correction = data.corrections?.correction?.artist;
if (!correction || !correction.name) {
// Cache null result
await redisClient.setEx(cacheKey, 2592000, "null");
return null;
}
const result = {
corrected:
correction.name.toLowerCase() !== artistName.toLowerCase(),
canonicalName: correction.name,
mbid: correction.mbid || undefined,
};
// Cache for 30 days
await redisClient.setEx(cacheKey, 2592000, JSON.stringify(result));
return result;
} catch (error: any) {
// Error 6 = "Artist not found" - cache negative result
if (error.response?.data?.error === 6) {
await redisClient.setEx(cacheKey, 2592000, "null");
return null;
}
logger.error(`Last.fm correction error for ${artistName}:`, error);
return null;
}
}
/** /**
* Get popular artists from Last.fm charts * Get popular artists from Last.fm charts
*/ */
@@ -844,7 +975,7 @@ class LastFmService {
// Return empty if no API key configured // Return empty if no API key configured
if (!this.apiKey) { if (!this.apiKey) {
console.warn( logger.warn(
"Last.fm: Cannot fetch chart artists - no API key configured" "Last.fm: Cannot fetch chart artists - no API key configured"
); );
return []; return [];
@@ -858,7 +989,7 @@ class LastFmService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
try { try {
@@ -901,7 +1032,7 @@ class LastFmService {
// Last fallback to Last.fm images (but filter placeholders) // Last fallback to Last.fm images (but filter placeholders)
if (!image) { if (!image) {
const lastFmImage = this.getBestImage(artist.image); const lastFmImage = this.getBestImage(normalizeToArray(artist.image));
if ( if (
lastFmImage && lastFmImage &&
!lastFmImage.includes( !lastFmImage.includes(
@@ -933,12 +1064,12 @@ class LastFmService {
JSON.stringify(detailedArtists) JSON.stringify(detailedArtists)
); );
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return detailedArtists; return detailedArtists;
} catch (error) { } catch (error) {
console.error("Last.fm chart artists error:", error); logger.error("Last.fm chart artists error:", error);
return []; return [];
} }
} }
File diff suppressed because it is too large Load Diff
+67 -7
View File
@@ -6,6 +6,7 @@
* instant mood mix generation through simple database lookups. * instant mood mix generation through simple database lookups.
*/ */
import { logger } from "../utils/logger";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
// Mood configuration with scoring rules // Mood configuration with scoring rules
@@ -16,6 +17,7 @@ export const MOOD_CONFIG = {
name: "Happy & Upbeat", name: "Happy & Upbeat",
color: "from-yellow-400 to-orange-500", color: "from-yellow-400 to-orange-500",
icon: "Smile", icon: "Smile",
moodTagKeywords: ["happy", "upbeat", "cheerful", "joyful", "positive"],
// Primary: ML mood prediction // Primary: ML mood prediction
primary: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 } }, primary: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 } },
// Fallback: basic audio features // Fallback: basic audio features
@@ -25,6 +27,7 @@ export const MOOD_CONFIG = {
name: "Melancholic", name: "Melancholic",
color: "from-blue-600 to-indigo-700", color: "from-blue-600 to-indigo-700",
icon: "CloudRain", icon: "CloudRain",
moodTagKeywords: ["sad", "melancholic", "melancholy", "dark", "somber"],
primary: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 } }, primary: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 } },
fallback: { valence: { max: 0.35 }, keyScale: "minor" }, fallback: { valence: { max: 0.35 }, keyScale: "minor" },
}, },
@@ -32,6 +35,7 @@ export const MOOD_CONFIG = {
name: "Chill & Relaxed", name: "Chill & Relaxed",
color: "from-teal-400 to-cyan-500", color: "from-teal-400 to-cyan-500",
icon: "Wind", icon: "Wind",
moodTagKeywords: ["relaxed", "chill", "calm", "mellow"],
primary: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 } }, primary: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 } },
fallback: { energy: { max: 0.5 }, arousal: { max: 0.5 } }, fallback: { energy: { max: 0.5 }, arousal: { max: 0.5 } },
}, },
@@ -39,6 +43,7 @@ export const MOOD_CONFIG = {
name: "High Energy", name: "High Energy",
color: "from-red-500 to-orange-600", color: "from-red-500 to-orange-600",
icon: "Zap", icon: "Zap",
moodTagKeywords: ["energetic", "powerful", "exciting"],
primary: { arousal: { min: 0.6 }, energy: { min: 0.7 } }, primary: { arousal: { min: 0.6 }, energy: { min: 0.7 } },
fallback: { bpm: { min: 120 }, energy: { min: 0.7 } }, fallback: { bpm: { min: 120 }, energy: { min: 0.7 } },
}, },
@@ -46,6 +51,7 @@ export const MOOD_CONFIG = {
name: "Dance Party", name: "Dance Party",
color: "from-pink-500 to-rose-600", color: "from-pink-500 to-rose-600",
icon: "PartyPopper", icon: "PartyPopper",
moodTagKeywords: ["party", "danceable", "groovy"],
primary: { moodParty: { min: 0.5 }, danceability: { min: 0.6 } }, primary: { moodParty: { min: 0.5 }, danceability: { min: 0.6 } },
fallback: { danceability: { min: 0.7 }, energy: { min: 0.6 } }, fallback: { danceability: { min: 0.7 }, energy: { min: 0.6 } },
}, },
@@ -53,6 +59,7 @@ export const MOOD_CONFIG = {
name: "Focus Mode", name: "Focus Mode",
color: "from-purple-600 to-violet-700", color: "from-purple-600 to-violet-700",
icon: "Brain", icon: "Brain",
moodTagKeywords: ["instrumental"],
primary: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 } }, primary: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 } },
fallback: { fallback: {
instrumentalness: { min: 0.5 }, instrumentalness: { min: 0.5 },
@@ -63,6 +70,7 @@ export const MOOD_CONFIG = {
name: "Deep Feels", name: "Deep Feels",
color: "from-gray-700 to-slate-800", color: "from-gray-700 to-slate-800",
icon: "Moon", icon: "Moon",
moodTagKeywords: ["sad", "melancholic", "emotional", "dark"],
primary: { moodSad: { min: 0.4 }, valence: { max: 0.4 } }, primary: { moodSad: { min: 0.4 }, valence: { max: 0.4 } },
fallback: { valence: { max: 0.35 }, keyScale: "minor" }, fallback: { valence: { max: 0.35 }, keyScale: "minor" },
}, },
@@ -70,6 +78,7 @@ export const MOOD_CONFIG = {
name: "Intense", name: "Intense",
color: "from-red-700 to-gray-900", color: "from-red-700 to-gray-900",
icon: "Flame", icon: "Flame",
moodTagKeywords: ["aggressive", "angry"],
primary: { moodAggressive: { min: 0.5 } }, primary: { moodAggressive: { min: 0.5 } },
fallback: { energy: { min: 0.8 }, arousal: { min: 0.7 } }, fallback: { energy: { min: 0.8 }, arousal: { min: 0.7 } },
}, },
@@ -77,6 +86,7 @@ export const MOOD_CONFIG = {
name: "Acoustic Vibes", name: "Acoustic Vibes",
color: "from-amber-500 to-yellow-600", color: "from-amber-500 to-yellow-600",
icon: "Guitar", icon: "Guitar",
moodTagKeywords: ["acoustic"],
primary: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } }, primary: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
fallback: { fallback: {
acousticness: { min: 0.6 }, acousticness: { min: 0.6 },
@@ -123,6 +133,7 @@ interface TrackWithAnalysis {
instrumentalness: number | null; instrumentalness: number | null;
bpm: number | null; bpm: number | null;
keyScale: string | null; keyScale: string | null;
moodTags: string[];
} }
export class MoodBucketService { export class MoodBucketService {
@@ -153,11 +164,12 @@ export class MoodBucketService {
instrumentalness: true, instrumentalness: true,
bpm: true, bpm: true,
keyScale: true, keyScale: true,
moodTags: true,
}, },
}); });
if (!track || track.analysisStatus !== "completed") { if (!track || track.analysisStatus !== "completed") {
console.log( logger.debug(
`[MoodBucket] Track ${trackId} not analyzed yet, skipping` `[MoodBucket] Track ${trackId} not analyzed yet, skipping`
); );
return []; return [];
@@ -199,7 +211,7 @@ export class MoodBucketService {
.filter(([_, score]) => score > 0) .filter(([_, score]) => score > 0)
.map(([mood]) => mood); .map(([mood]) => mood);
console.log( logger.debug(
`[MoodBucket] Track ${trackId} assigned to moods: ${ `[MoodBucket] Track ${trackId} assigned to moods: ${
assignedMoods.join(", ") || "none" assignedMoods.join(", ") || "none"
}` }`
@@ -226,6 +238,16 @@ export class MoodBucketService {
acoustic: 0, acoustic: 0,
}; };
// Check if we have individual mood fields OR moodTags
const hasIndividualMoods = track.moodHappy !== null || track.moodSad !== null;
const hasMoodTags = track.moodTags && track.moodTags.length > 0;
// If we have moodTags but no individual mood fields, parse moodTags
if (!hasIndividualMoods && hasMoodTags) {
return this.calculateMoodScoresFromTags(track.moodTags);
}
// Otherwise use original logic
for (const [mood, config] of Object.entries(MOOD_CONFIG)) { for (const [mood, config] of Object.entries(MOOD_CONFIG)) {
const rules = isEnhanced ? config.primary : config.fallback; const rules = isEnhanced ? config.primary : config.fallback;
const score = this.evaluateMoodRules(track, rules); const score = this.evaluateMoodRules(track, rules);
@@ -235,6 +257,43 @@ export class MoodBucketService {
return scores; return scores;
} }
/**
* Calculate mood scores from moodTags array
* Used when individual mood fields are not populated
*/
private calculateMoodScoresFromTags(moodTags: string[]): Record<MoodType, number> {
const scores: Record<MoodType, number> = {
happy: 0,
sad: 0,
chill: 0,
energetic: 0,
party: 0,
focus: 0,
melancholy: 0,
aggressive: 0,
acoustic: 0,
};
const normalizedTags = moodTags.map(tag => tag.toLowerCase());
for (const [mood, config] of Object.entries(MOOD_CONFIG)) {
const keywords = config.moodTagKeywords;
let matchCount = 0;
for (const keyword of keywords) {
if (normalizedTags.includes(keyword)) {
matchCount++;
}
}
if (matchCount > 0) {
scores[mood as MoodType] = Math.min(1.0, 0.3 + (matchCount - 1) * 0.2);
}
}
return scores;
}
/** /**
* Evaluate mood rules against track features * Evaluate mood rules against track features
* Returns a score 0-1 based on how well the track matches the rules * Returns a score 0-1 based on how well the track matches the rules
@@ -380,7 +439,7 @@ export class MoodBucketService {
}); });
if (moodBuckets.length < 8) { if (moodBuckets.length < 8) {
console.log( logger.debug(
`[MoodBucket] Not enough tracks for mood ${mood}: ${moodBuckets.length}` `[MoodBucket] Not enough tracks for mood ${mood}: ${moodBuckets.length}`
); );
return null; return null;
@@ -465,7 +524,7 @@ export class MoodBucketService {
}, },
}); });
console.log( logger.debug(
`[MoodBucket] Saved ${mood} mix for user ${userId} (${mix.trackCount} tracks)` `[MoodBucket] Saved ${mood} mix for user ${userId} (${mix.trackCount} tracks)`
); );
@@ -532,7 +591,7 @@ export class MoodBucketService {
let assigned = 0; let assigned = 0;
let skip = 0; let skip = 0;
console.log("[MoodBucket] Starting backfill of all analyzed tracks..."); logger.debug("[MoodBucket] Starting backfill of all analyzed tracks...");
while (true) { while (true) {
const tracks = await prisma.track.findMany({ const tracks = await prisma.track.findMany({
@@ -555,6 +614,7 @@ export class MoodBucketService {
instrumentalness: true, instrumentalness: true,
bpm: true, bpm: true,
keyScale: true, keyScale: true,
moodTags: true,
}, },
skip, skip,
take: batchSize, take: batchSize,
@@ -601,12 +661,12 @@ export class MoodBucketService {
} }
skip += batchSize; skip += batchSize;
console.log( logger.debug(
`[MoodBucket] Backfill progress: ${processed} tracks processed, ${assigned} mood assignments` `[MoodBucket] Backfill progress: ${processed} tracks processed, ${assigned} mood assignments`
); );
} }
console.log( logger.debug(
`[MoodBucket] Backfill complete: ${processed} tracks processed, ${assigned} mood assignments` `[MoodBucket] Backfill complete: ${processed} tracks processed, ${assigned} mood assignments`
); );
return { processed, assigned }; return { processed, assigned };
+195 -157
View File
@@ -1,11 +1,18 @@
import * as fs from "fs"; import * as fs from "fs";
import { logger } from "../utils/logger";
import * as path from "path"; import * as path from "path";
import { parseFile } from "music-metadata"; import { parseFile } from "music-metadata";
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { CoverArtExtractor } from "./coverArtExtractor"; import { CoverArtExtractor } from "./coverArtExtractor";
import { deezerService } from "./deezer"; import { deezerService } from "./deezer";
import { normalizeArtistName, areArtistNamesSimilar, canonicalizeVariousArtists } from "../utils/artistNormalization"; import {
normalizeArtistName,
areArtistNamesSimilar,
canonicalizeVariousArtists,
extractPrimaryArtist,
parseArtistFromPath,
} from "../utils/artistNormalization";
// Supported audio formats // Supported audio formats
const AUDIO_EXTENSIONS = new Set([ const AUDIO_EXTENSIONS = new Set([
@@ -64,11 +71,11 @@ export class MusicScannerService {
duration: 0, duration: 0,
}; };
console.log(`Starting library scan: ${musicPath}`); logger.debug(`Starting library scan: ${musicPath}`);
// Step 1: Find all audio files // Step 1: Find all audio files
const audioFiles = await this.findAudioFiles(musicPath); const audioFiles = await this.findAudioFiles(musicPath);
console.log(`Found ${audioFiles.length} audio files`); logger.debug(`Found ${audioFiles.length} audio files`);
// Step 2: Get existing tracks from database // Step 2: Get existing tracks from database
const existingTracks = await prisma.track.findMany({ const existingTracks = await prisma.track.findMany({
@@ -135,7 +142,7 @@ export class MusicScannerService {
}; };
result.errors.push(error); result.errors.push(error);
progress.errors.push(error); progress.errors.push(error);
console.error(`Error processing ${audioFile}:`, err); logger.error(`Error processing ${audioFile}:`, err);
} finally { } finally {
filesScanned++; filesScanned++;
progress.filesScanned = filesScanned; progress.filesScanned = filesScanned;
@@ -161,7 +168,7 @@ export class MusicScannerService {
}, },
}); });
result.tracksRemoved = tracksToRemove.length; result.tracksRemoved = tracksToRemove.length;
console.log(`Removed ${tracksToRemove.length} missing tracks`); logger.debug(`Removed ${tracksToRemove.length} missing tracks`);
} }
// Step 5: Clean up orphaned albums (albums with no tracks) // Step 5: Clean up orphaned albums (albums with no tracks)
@@ -173,7 +180,7 @@ export class MusicScannerService {
}); });
if (orphanedAlbums.length > 0) { if (orphanedAlbums.length > 0) {
console.log(`Removing ${orphanedAlbums.length} orphaned albums...`); logger.debug(`Removing ${orphanedAlbums.length} orphaned albums...`);
await prisma.album.deleteMany({ await prisma.album.deleteMany({
where: { where: {
id: { in: orphanedAlbums.map((a) => a.id) }, id: { in: orphanedAlbums.map((a) => a.id) },
@@ -190,7 +197,13 @@ export class MusicScannerService {
}); });
if (orphanedArtists.length > 0) { if (orphanedArtists.length > 0) {
console.log(`Removing ${orphanedArtists.length} orphaned artists: ${orphanedArtists.map(a => a.name).join(', ')}`); logger.debug(
`Removing ${
orphanedArtists.length
} orphaned artists: ${orphanedArtists
.map((a) => a.name)
.join(", ")}`
);
await prisma.artist.deleteMany({ await prisma.artist.deleteMany({
where: { where: {
id: { in: orphanedArtists.map((a) => a.id) }, id: { in: orphanedArtists.map((a) => a.id) },
@@ -199,79 +212,13 @@ export class MusicScannerService {
} }
result.duration = Date.now() - startTime; result.duration = Date.now() - startTime;
console.log( logger.debug(
`Scan complete: +${result.tracksAdded} ~${result.tracksUpdated} -${result.tracksRemoved} (${result.duration}ms)` `Scan complete: +${result.tracksAdded} ~${result.tracksUpdated} -${result.tracksRemoved} (${result.duration}ms)`
); );
return result; 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 * Check if a file path is within the discovery folder
* Discovery albums are stored in paths like "discovery/Artist/Album/track.flac" * Discovery albums are stored in paths like "discovery/Artist/Album/track.flac"
@@ -294,12 +241,13 @@ export class MusicScannerService {
return str return str
.toLowerCase() .toLowerCase()
.trim() .trim()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // Remove diacritics (café → cafe) .normalize("NFD")
.replace(/[''´`]/g, "'") // Normalize apostrophes .replace(/[\u0300-\u036f]/g, "") // Remove diacritics (café → cafe)
.replace(/[""„]/g, '"') // Normalize quotes .replace(/[''´`]/g, "'") // Normalize apostrophes
.replace(/[–—−]/g, '-') // Normalize dashes .replace(/[""„]/g, '"') // Normalize quotes
.replace(/\s+/g, ' ') // Collapse whitespace .replace(/[–—−]/g, "-") // Normalize dashes
.replace(/[^\w\s'"-]/g, ''); // Remove other special chars .replace(/\s+/g, " ") // Collapse whitespace
.replace(/[^\w\s'"-]/g, ""); // Remove other special chars
} }
/** /**
@@ -314,16 +262,23 @@ export class MusicScannerService {
const normalizedArtist = this.normalizeForMatching(artistName); const normalizedArtist = this.normalizeForMatching(artistName);
const normalizedAlbum = this.normalizeForMatching(albumTitle); 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}"`); // Also try with primary artist extracted (handles "Artist A feat. Artist B")
const primaryArtist = extractPrimaryArtist(artistName);
const normalizedPrimaryArtist =
this.normalizeForMatching(primaryArtist);
logger.debug(
`[Scanner] Checking discovery: "${artistName}" -> "${normalizedArtist}"`
);
if (primaryArtist !== artistName) { if (primaryArtist !== artistName) {
console.log(`[Scanner] Primary artist: "${primaryArtist}" → "${normalizedPrimaryArtist}"`); logger.debug(
`[Scanner] Primary artist: "${primaryArtist}" -> "${normalizedPrimaryArtist}"`
);
} }
console.log(`[Scanner] Album: "${albumTitle}" → "${normalizedAlbum}"`); logger.debug(
`[Scanner] Album: "${albumTitle}" -> "${normalizedAlbum}"`
);
try { try {
// Get all discovery jobs (pending, processing, or recently completed) // Get all discovery jobs (pending, processing, or recently completed)
@@ -334,16 +289,26 @@ export class MusicScannerService {
}, },
}); });
console.log(`[Scanner] Found ${discoveryJobs.length} discovery jobs to check`); logger.debug(
`[Scanner] Found ${discoveryJobs.length} discovery jobs to check`
);
// Pass 1: Exact match after normalization // Pass 1: Exact match after normalization
for (const job of discoveryJobs) { for (const job of discoveryJobs) {
const metadata = job.metadata as any; const metadata = job.metadata as any;
const jobArtist = this.normalizeForMatching(metadata?.artistName || ""); const jobArtist = this.normalizeForMatching(
const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || ""); metadata?.artistName || ""
);
const jobAlbum = this.normalizeForMatching(
metadata?.albumTitle || ""
);
if ((jobArtist === normalizedArtist || jobArtist === normalizedPrimaryArtist) && jobAlbum === normalizedAlbum) { if (
console.log(`[Scanner] EXACT MATCH: job ${job.id}`); (jobArtist === normalizedArtist ||
jobArtist === normalizedPrimaryArtist) &&
jobAlbum === normalizedAlbum
) {
logger.debug(`[Scanner] EXACT MATCH: job ${job.id}`);
return true; return true;
} }
} }
@@ -351,23 +316,31 @@ export class MusicScannerService {
// Pass 2: Partial match fallback (handles "Album" vs "Album (Deluxe)") // Pass 2: Partial match fallback (handles "Album" vs "Album (Deluxe)")
for (const job of discoveryJobs) { for (const job of discoveryJobs) {
const metadata = job.metadata as any; const metadata = job.metadata as any;
const jobArtist = this.normalizeForMatching(metadata?.artistName || ""); const jobArtist = this.normalizeForMatching(
const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || ""); metadata?.artistName || ""
);
const jobAlbum = this.normalizeForMatching(
metadata?.albumTitle || ""
);
// Try matching both full artist name and extracted primary artist // Try matching both full artist name and extracted primary artist
const artistMatch = jobArtist === normalizedArtist || const artistMatch =
jobArtist === normalizedPrimaryArtist || jobArtist === normalizedArtist ||
normalizedArtist.includes(jobArtist) || jobArtist === normalizedPrimaryArtist ||
jobArtist.includes(normalizedArtist) || normalizedArtist.includes(jobArtist) ||
normalizedPrimaryArtist.includes(jobArtist) || jobArtist.includes(normalizedArtist) ||
jobArtist.includes(normalizedPrimaryArtist); normalizedPrimaryArtist.includes(jobArtist) ||
const albumMatch = jobAlbum === normalizedAlbum || jobArtist.includes(normalizedPrimaryArtist);
normalizedAlbum.includes(jobAlbum) || const albumMatch =
jobAlbum.includes(normalizedAlbum); jobAlbum === normalizedAlbum ||
normalizedAlbum.includes(jobAlbum) ||
jobAlbum.includes(normalizedAlbum);
if (artistMatch && albumMatch) { if (artistMatch && albumMatch) {
console.log(`[Scanner] PARTIAL MATCH: job ${job.id}`); logger.debug(`[Scanner] PARTIAL MATCH: job ${job.id}`);
console.log(`[Scanner] Job: "${jobArtist}" - "${jobAlbum}"`); logger.debug(
`[Scanner] Job: "${jobArtist}" - "${jobAlbum}"`
);
return true; return true;
} }
} }
@@ -376,59 +349,79 @@ export class MusicScannerService {
// If the album title matches exactly, this track is likely a featured artist on a discovery album // If the album title matches exactly, this track is likely a featured artist on a discovery album
for (const job of discoveryJobs) { for (const job of discoveryJobs) {
const metadata = job.metadata as any; const metadata = job.metadata as any;
const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || ""); const jobAlbum = this.normalizeForMatching(
metadata?.albumTitle || ""
);
if (jobAlbum === normalizedAlbum && normalizedAlbum.length > 3) { if (
console.log(`[Scanner] ALBUM-ONLY MATCH (featured artist): job ${job.id}`); jobAlbum === normalizedAlbum &&
console.log(`[Scanner] Track artist "${normalizedArtist}" is likely featured on "${jobAlbum}"`); normalizedAlbum.length > 3
) {
logger.debug(
`[Scanner] ALBUM-ONLY MATCH (featured artist): job ${job.id}`
);
logger.debug(
`[Scanner] Track artist "${normalizedArtist}" is likely featured on "${jobAlbum}"`
);
return true; return true;
} }
} }
// Pass 4: Check DiscoveryAlbum table (for already processed albums) by album title // Pass 4: Check DiscoveryAlbum table (for already processed albums) by album title
const discoveryAlbumByTitle = await prisma.discoveryAlbum.findFirst({ const discoveryAlbumByTitle = await prisma.discoveryAlbum.findFirst(
where: { {
albumTitle: { equals: albumTitle, mode: "insensitive" }, where: {
status: { in: ["ACTIVE", "LIKED"] }, albumTitle: { equals: albumTitle, mode: "insensitive" },
}, status: { in: ["ACTIVE", "LIKED"] },
}); },
}
);
if (discoveryAlbumByTitle) { if (discoveryAlbumByTitle) {
console.log(`[Scanner] DiscoveryAlbum match (by title): ${discoveryAlbumByTitle.id}`); logger.debug(
`[Scanner] DiscoveryAlbum match (by title): ${discoveryAlbumByTitle.id}`
);
return true; return true;
} }
// Pass 5: Check if artist name matches any discovery album // Pass 5: Check if artist name matches any discovery album
// This catches cases where Lidarr downloads a different album than requested // This catches cases where Lidarr downloads a different album than requested
// e.g., requested "Broods - Broods" but got "Broods - Evergreen" // e.g., requested "Broods - Broods" but got "Broods - Evergreen"
const discoveryAlbumByArtist = await prisma.discoveryAlbum.findFirst({ const discoveryAlbumByArtist =
where: { await prisma.discoveryAlbum.findFirst({
artistName: { equals: artistName, mode: "insensitive" }, where: {
status: { in: ["ACTIVE", "LIKED", "DELETED"] }, // Include DELETED to catch cleanup scenarios artistName: { equals: artistName, mode: "insensitive" },
}, status: { in: ["ACTIVE", "LIKED", "DELETED"] }, // Include DELETED to catch cleanup scenarios
}); },
});
if (discoveryAlbumByArtist) { if (discoveryAlbumByArtist) {
// Double-check: only match if this artist has NO library albums yet // 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 // This prevents marking albums from artists that exist in both library and discovery
const existingLibraryAlbum = await prisma.album.findFirst({ const existingLibraryAlbum = await prisma.album.findFirst({
where: { where: {
artist: { name: { equals: artistName, mode: "insensitive" } }, artist: {
name: { equals: artistName, mode: "insensitive" },
},
location: "LIBRARY", location: "LIBRARY",
}, },
}); });
if (!existingLibraryAlbum) { if (!existingLibraryAlbum) {
console.log(`[Scanner] DiscoveryAlbum match (by artist): ${discoveryAlbumByArtist.id}`); logger.debug(
console.log(`[Scanner] Artist "${artistName}" is a discovery-only artist`); `[Scanner] DiscoveryAlbum match (by artist): ${discoveryAlbumByArtist.id}`
);
logger.debug(
`[Scanner] Artist "${artistName}" is a discovery-only artist`
);
return true; return true;
} }
} }
console.log(`[Scanner] No discovery match found`); logger.debug(`[Scanner] No discovery match found`);
return false; return false;
} catch (error) { } catch (error) {
console.error(`[Scanner] Error checking discovery status:`, error); logger.error(`[Scanner] Error checking discovery status:`, error);
return false; return false;
} }
} }
@@ -489,17 +482,36 @@ export class MusicScannerService {
let rawArtistName = let rawArtistName =
metadata.common.albumartist || metadata.common.albumartist ||
metadata.common.artist || metadata.common.artist ||
"Unknown Artist"; "";
// Folder fallback: If metadata is empty, try to parse from folder structure
if (!rawArtistName || rawArtistName.trim() === "") {
const folderPath = path.dirname(relativePath);
const folderName = path.basename(folderPath);
const parsedArtist = parseArtistFromPath(folderName);
if (parsedArtist) {
logger.debug(
`[Scanner] No metadata artist found, using folder: "${folderName}" -> "${parsedArtist}"`
);
rawArtistName = parsedArtist;
} else {
rawArtistName = "Unknown Artist";
logger.warn(
`[Scanner] Unknown Artist assigned for: ${relativePath} (no metadata, folder parse failed: "${folderName}")`
);
}
}
const albumTitle = metadata.common.album || "Unknown Album"; const albumTitle = metadata.common.album || "Unknown Album";
const year = metadata.common.year || null; const year = metadata.common.year || null;
// ALWAYS extract primary artist first - this handles both: // ALWAYS extract primary artist first - this handles both:
// - Featured artists: "Artist A feat. Artist B" -> "Artist A" // - Featured artists: "Artist A feat. Artist B" -> "Artist A"
// - Collaborations: "Artist A & Artist B" -> "Artist A" // - Collaborations: "Artist A & Artist B" -> "Artist A"
// Band names like "Of Mice & Men" are preserved because extractPrimaryArtist // Band names like "Of Mice & Men" are preserved because extractPrimaryArtist
// only splits on " feat.", " ft.", " featuring ", " & ", etc. (with spaces) // only splits on " feat.", " ft.", " featuring ", " & ", etc. (with spaces)
const extractedPrimaryArtist = this.extractPrimaryArtist(rawArtistName); const extractedPrimaryArtist = extractPrimaryArtist(rawArtistName);
let artistName = extractedPrimaryArtist; let artistName = extractedPrimaryArtist;
// Canonicalize Various Artists variations (VA, V.A., <Various Artists>, etc.) // Canonicalize Various Artists variations (VA, V.A., <Various Artists>, etc.)
@@ -511,7 +523,7 @@ export class MusicScannerService {
let artist = await prisma.artist.findFirst({ let artist = await prisma.artist.findFirst({
where: { normalizedName: normalizedPrimaryName }, where: { normalizedName: normalizedPrimaryName },
}); });
// If no match with primary name and we actually extracted something, // If no match with primary name and we actually extracted something,
// also try the full raw name (for bands like "Of Mice & Men") // also try the full raw name (for bands like "Of Mice & Men")
if (!artist && extractedPrimaryArtist !== rawArtistName) { if (!artist && extractedPrimaryArtist !== rawArtistName) {
@@ -531,11 +543,15 @@ export class MusicScannerService {
// If we found an artist, optionally update to better capitalization // If we found an artist, optionally update to better capitalization
if (artist && artist.name !== artistName) { if (artist && artist.name !== artistName) {
// Check if the new name has better capitalization (starts with uppercase) // Check if the new name has better capitalization (starts with uppercase)
const currentNameIsLowercase = artist.name[0] === artist.name[0].toLowerCase(); const currentNameIsLowercase =
const newNameIsCapitalized = artistName[0] === artistName[0].toUpperCase(); artist.name[0] === artist.name[0].toLowerCase();
const newNameIsCapitalized =
artistName[0] === artistName[0].toUpperCase();
if (currentNameIsLowercase && newNameIsCapitalized) { if (currentNameIsLowercase && newNameIsCapitalized) {
console.log(`Updating artist name capitalization: "${artist.name}" -> "${artistName}"`); logger.debug(
`Updating artist name capitalization: "${artist.name}" -> "${artistName}"`
);
artist = await prisma.artist.update({ artist = await prisma.artist.update({
where: { id: artist.id }, where: { id: artist.id },
data: { name: artistName }, data: { name: artistName },
@@ -550,17 +566,27 @@ export class MusicScannerService {
where: { where: {
normalizedName: { normalizedName: {
// Get artists whose normalized names start with similar prefix // Get artists whose normalized names start with similar prefix
startsWith: normalizedArtistName.substring(0, Math.min(3, normalizedArtistName.length)), startsWith: normalizedArtistName.substring(
0,
Math.min(3, normalizedArtistName.length)
),
}, },
}, },
select: { id: true, name: true, normalizedName: true, mbid: true }, select: {
id: true,
name: true,
normalizedName: true,
mbid: true,
},
}); });
// Check for fuzzy matches // Check for fuzzy matches
for (const candidate of similarArtists) { for (const candidate of similarArtists) {
if (areArtistNamesSimilar(artistName, candidate.name, 95)) { if (areArtistNamesSimilar(artistName, candidate.name, 95)) {
console.log(`Fuzzy match found: "${artistName}" -> "${candidate.name}"`); logger.debug(
artist = candidate; `Fuzzy match found: "${artistName}" -> "${candidate.name}"`
);
artist = candidate as any;
break; break;
} }
} }
@@ -579,13 +605,15 @@ export class MusicScannerService {
const tempArtist = await prisma.artist.findFirst({ const tempArtist = await prisma.artist.findFirst({
where: { where: {
normalizedName: normalizedArtistName, normalizedName: normalizedArtistName,
mbid: { startsWith: 'temp-' }, mbid: { startsWith: "temp-" },
}, },
}); });
if (tempArtist) { if (tempArtist) {
// Consolidate: update temp artist to real MBID // Consolidate: update temp artist to real MBID
console.log(`[SCANNER] Consolidating temp artist "${tempArtist.name}" with real MBID: ${artistMbid}`); logger.debug(
`[SCANNER] Consolidating temp artist "${tempArtist.name}" with real MBID: ${artistMbid}`
);
artist = await prisma.artist.update({ artist = await prisma.artist.update({
where: { id: tempArtist.id }, where: { id: tempArtist.id },
data: { mbid: artistMbid }, data: { mbid: artistMbid },
@@ -635,8 +663,11 @@ export class MusicScannerService {
// 2. Check if artist+album matches a discovery download job // 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) // 3. Check if artist is a discovery-only artist (has DISCOVER albums but no LIBRARY albums)
const isDiscoveryByPath = this.isDiscoveryPath(relativePath); const isDiscoveryByPath = this.isDiscoveryPath(relativePath);
const isDiscoveryByJob = await this.isDiscoveryDownload(artistName, albumTitle); const isDiscoveryByJob = await this.isDiscoveryDownload(
artistName,
albumTitle
);
// Check if this artist is discovery-only (has no LIBRARY albums) // Check if this artist is discovery-only (has no LIBRARY albums)
// If so, any new albums from them should also be DISCOVER // If so, any new albums from them should also be DISCOVER
let isDiscoveryArtist = false; let isDiscoveryArtist = false;
@@ -645,18 +676,23 @@ export class MusicScannerService {
where: { artistId: artist.id }, where: { artistId: artist.id },
select: { location: true }, select: { location: true },
}); });
// Artist is discovery-only if they have albums but NONE are LIBRARY // Artist is discovery-only if they have albums but NONE are LIBRARY
if (artistAlbums.length > 0) { if (artistAlbums.length > 0) {
const hasLibraryAlbums = artistAlbums.some(a => a.location === "LIBRARY"); const hasLibraryAlbums = artistAlbums.some(
(a) => a.location === "LIBRARY"
);
isDiscoveryArtist = !hasLibraryAlbums; isDiscoveryArtist = !hasLibraryAlbums;
if (isDiscoveryArtist) { if (isDiscoveryArtist) {
console.log(`[Scanner] Discovery-only artist detected: ${artistName}`); logger.debug(
`[Scanner] Discovery-only artist detected: ${artistName}`
);
} }
} }
} }
const isDiscoveryAlbum = isDiscoveryByPath || isDiscoveryByJob || isDiscoveryArtist; const isDiscoveryAlbum =
isDiscoveryByPath || isDiscoveryByJob || isDiscoveryArtist;
album = await prisma.album.create({ album = await prisma.album.create({
data: { data: {
@@ -709,10 +745,11 @@ export class MusicScannerService {
} }
if (needsExtraction) { if (needsExtraction) {
const coverPath = await this.coverArtExtractor.extractCoverArt( const coverPath =
absolutePath, await this.coverArtExtractor.extractCoverArt(
album.id absolutePath,
); album.id
);
if (coverPath) { if (coverPath) {
await prisma.album.update({ await prisma.album.update({
where: { id: album.id }, where: { id: album.id },
@@ -721,10 +758,11 @@ export class MusicScannerService {
} else { } else {
// No embedded art, try fetching from Deezer // No embedded art, try fetching from Deezer
try { try {
const deezerCover = await deezerService.getAlbumCover( const deezerCover =
artistName, await deezerService.getAlbumCover(
albumTitle artistName,
); albumTitle
);
if (deezerCover) { if (deezerCover) {
await prisma.album.update({ await prisma.album.update({
where: { id: album.id }, where: { id: album.id },
+152 -37
View File
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { logger } from "../utils/logger";
import { redisClient } from "../utils/redis"; import { redisClient } from "../utils/redis";
import { rateLimiter } from "./rateLimiter"; import { rateLimiter } from "./rateLimiter";
@@ -27,7 +28,7 @@ class MusicBrainzService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (err) { } catch (err) {
console.warn("Redis get error:", err); logger.warn("Redis get error:", err);
} }
// Use global rate limiter instead of local rate limiting // Use global rate limiter instead of local rate limiting
@@ -39,7 +40,7 @@ class MusicBrainzService {
const actualTtl = data === null ? 3600 : ttlSeconds; const actualTtl = data === null ? 3600 : ttlSeconds;
await redisClient.setEx(cacheKey, actualTtl, JSON.stringify(data)); await redisClient.setEx(cacheKey, actualTtl, JSON.stringify(data));
} catch (err) { } catch (err) {
console.warn("Redis set error:", err); logger.warn("Redis set error:", err);
} }
return data; return data;
@@ -343,10 +344,10 @@ class MusicBrainzService {
const allRecordings = response.data.recordings || []; const allRecordings = response.data.recordings || [];
console.log( logger.debug(
`[MusicBrainz] Query: "${trackTitle}" by "${artistName}"` `[MusicBrainz] Query: "${trackTitle}" by "${artistName}"`
); );
console.log( logger.debug(
`[MusicBrainz] Found ${allRecordings.length} total recordings` `[MusicBrainz] Found ${allRecordings.length} total recordings`
); );
@@ -358,7 +359,7 @@ class MusicBrainzService {
.slice(0, 2) .slice(0, 2)
.map((r: any) => r["release-group"]?.title || "?") .map((r: any) => r["release-group"]?.title || "?")
.join(", "); .join(", ");
console.log( logger.debug(
` ${i + 1}. [${disambig}] → ${ ` ${i + 1}. [${disambig}] → ${
albumNames || "(no albums)" albumNames || "(no albums)"
}` }`
@@ -378,7 +379,7 @@ class MusicBrainzService {
return true; return true;
}); });
console.log( logger.debug(
`[MusicBrainz] After filtering live/demo: ${recordings.length} studio recordings` `[MusicBrainz] After filtering live/demo: ${recordings.length} studio recordings`
); );
@@ -425,20 +426,28 @@ class MusicBrainzService {
const strippedArtist = this.stripPunctuation(artistName); const strippedArtist = this.stripPunctuation(artistName);
if (strippedTitle !== normalizedTitle) { if (strippedTitle !== normalizedTitle) {
console.log(`[MusicBrainz] Trying punctuation-stripped search: "${strippedTitle}" by ${strippedArtist}`); logger.debug(
`[MusicBrainz] Trying punctuation-stripped search: "${strippedTitle}" by ${strippedArtist}`
);
const strippedQuery = `${strippedTitle} AND artist:${strippedArtist}`; const strippedQuery = `${strippedTitle} AND artist:${strippedArtist}`;
const strippedResponse = await this.client.get("/recording", { const strippedResponse = await this.client.get(
params: { "/recording",
query: strippedQuery, {
limit: 10, params: {
fmt: "json", query: strippedQuery,
inc: "releases+release-groups+artists", limit: 10,
}, fmt: "json",
}); inc: "releases+release-groups+artists",
},
}
);
const strippedRecordings = strippedResponse.data.recordings || []; const strippedRecordings =
console.log(`[MusicBrainz] Punctuation-stripped search found ${strippedRecordings.length} recordings`); strippedResponse.data.recordings || [];
logger.debug(
`[MusicBrainz] Punctuation-stripped search found ${strippedRecordings.length} recordings`
);
for (const rec of strippedRecordings) { for (const rec of strippedRecordings) {
const recArtist = const recArtist =
@@ -448,11 +457,18 @@ class MusicBrainzService {
if ( if (
recArtist recArtist
.toLowerCase() .toLowerCase()
.includes(strippedArtist.toLowerCase().split(" ")[0]) .includes(
strippedArtist
.toLowerCase()
.split(" ")[0]
)
) { ) {
const result = this.extractAlbumFromRecording(rec); const result =
this.extractAlbumFromRecording(rec);
if (result) { if (result) {
console.log(`[MusicBrainz] ✓ Found via punctuation-stripped search: ${result.albumName}`); logger.debug(
`[MusicBrainz] Found via punctuation-stripped search: ${result.albumName}`
);
return result; return result;
} }
} }
@@ -464,34 +480,45 @@ class MusicBrainzService {
// Try each recording until we find one with a good (non-bootleg) album // Try each recording until we find one with a good (non-bootleg) album
for (const rec of recordings) { for (const rec of recordings) {
const disambig = rec.disambiguation || "(no disambiguation)"; const disambig =
console.log(`[MusicBrainz] Trying recording: "${rec.title}" [${disambig}]`); rec.disambiguation || "(no disambiguation)";
logger.debug(
`[MusicBrainz] Trying recording: "${rec.title}" [${disambig}]`
);
const result = this.extractAlbumFromRecording(rec, false); const result = this.extractAlbumFromRecording(rec, false);
if (result) { if (result) {
console.log(`[MusicBrainz] ✓ Found album: "${result.albumName}" (MBID: ${result.albumMbid})`); logger.debug(
`[MusicBrainz] Found album: "${result.albumName}" (MBID: ${result.albumMbid})`
);
return result; // Found a good album return result; // Found a good album
} else { } else {
console.log(`[MusicBrainz] ✗ No valid album found for this recording`); logger.debug(
`[MusicBrainz] No valid album found for this recording`
);
} }
} }
// Fallback: Try again accepting Singles/EPs as last resort // Fallback: Try again accepting Singles/EPs as last resort
console.log(`[MusicBrainz] No official albums found, trying to find Singles/EPs...`); logger.debug(
`[MusicBrainz] No official albums found, trying to find Singles/EPs...`
);
for (const rec of recordings) { for (const rec of recordings) {
const result = this.extractAlbumFromRecording(rec, true); const result = this.extractAlbumFromRecording(rec, true);
if (result) { if (result) {
console.log(`[MusicBrainz] ✓ Found Single/EP: "${result.albumName}" (MBID: ${result.albumMbid})`); logger.debug(
`[MusicBrainz] Found Single/EP: "${result.albumName}" (MBID: ${result.albumMbid})`
);
return result; return result;
} }
} }
// No good albums found in any recording // No good albums found in any recording
console.log( logger.debug(
`[MusicBrainz] No official albums or singles found for "${trackTitle}" by ${artistName} (checked ${recordings.length} recordings)` `[MusicBrainz] No official albums or singles found for "${trackTitle}" by ${artistName} (checked ${recordings.length} recordings)`
); );
return null; return null;
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
"MusicBrainz recording search error:", "MusicBrainz recording search error:",
error.message error.message
); );
@@ -505,7 +532,10 @@ class MusicBrainzService {
* Prioritizes studio albums and filters out compilations, live albums, and bootlegs * Prioritizes studio albums and filters out compilations, live albums, and bootlegs
* @param allowSingles - If true, accepts Singles/EPs as a fallback (lower threshold) * @param allowSingles - If true, accepts Singles/EPs as a fallback (lower threshold)
*/ */
private extractAlbumFromRecording(recording: any, allowSingles: boolean = false): { private extractAlbumFromRecording(
recording: any,
allowSingles: boolean = false
): {
albumName: string; albumName: string;
albumMbid: string; albumMbid: string;
artistMbid: string; artistMbid: string;
@@ -582,10 +612,12 @@ class MusicBrainzService {
r.release["release-group"]?.title || r.release.title; r.release["release-group"]?.title || r.release.title;
return `"${title}" (${r.score})`; return `"${title}" (${r.score})`;
}); });
console.log( logger.debug(
`[MusicBrainz] Skipping recording - no ${modeText} found in ${ `[MusicBrainz] Skipping recording - no ${modeText} found in ${
releases.length releases.length
} releases (threshold: ${threshold}). Top scores: ${topScores.join(", ")}` } releases (threshold: ${threshold}). Top scores: ${topScores.join(
", "
)}`
); );
return null; return null;
} }
@@ -597,7 +629,7 @@ class MusicBrainzService {
return null; return null;
} }
console.log( logger.debug(
`[MusicBrainz] Selected "${releaseGroup.title}" (score: ${bestResult.score}) from ${releases.length} releases` `[MusicBrainz] Selected "${releaseGroup.title}" (score: ${bestResult.score}) from ${releases.length} releases`
); );
@@ -614,14 +646,19 @@ class MusicBrainzService {
* Clear cached recording search result * Clear cached recording search result
* Useful for retrying failed lookups * Useful for retrying failed lookups
*/ */
async clearRecordingCache(trackTitle: string, artistName: string): Promise<boolean> { async clearRecordingCache(
trackTitle: string,
artistName: string
): Promise<boolean> {
const cacheKey = `mb:search:recording:${artistName}:${trackTitle}`; const cacheKey = `mb:search:recording:${artistName}:${trackTitle}`;
try { try {
await redisClient.del(cacheKey); await redisClient.del(cacheKey);
console.log(`[MusicBrainz] Cleared cache for: "${trackTitle}" by ${artistName}`); logger.debug(
`[MusicBrainz] Cleared cache for: "${trackTitle}" by ${artistName}`
);
return true; return true;
} catch (err) { } catch (err) {
console.warn("Redis del error:", err); logger.warn("Redis del error:", err);
return false; return false;
} }
} }
@@ -644,13 +681,91 @@ class MusicBrainzService {
} }
} }
console.log(`[MusicBrainz] Cleared ${cleared} stale null cache entries`); logger.debug(
`[MusicBrainz] Cleared ${cleared} stale null cache entries`
);
return cleared; return cleared;
} catch (err) { } catch (err) {
console.error("Error clearing stale caches:", err); logger.error("Error clearing stale caches:", err);
return 0; return 0;
} }
} }
/**
* Get track list for an album by release group MBID
* Uses the first official release from the release group
*/
async getAlbumTracks(
rgMbid: string
): Promise<Array<{ title: string; position?: number; duration?: number }>> {
const cacheKey = `mb:albumtracks:${rgMbid}`;
return this.cachedRequest(cacheKey, async () => {
try {
// Step 1: Get releases from the release group
const rgResponse = await this.client.get(
`/release-group/${rgMbid}`,
{
params: {
inc: "releases",
fmt: "json",
},
}
);
const releases = rgResponse.data?.releases || [];
if (releases.length === 0) {
logger.debug(
`[MusicBrainz] No releases found for release group ${rgMbid}`
);
return [];
}
// Prefer official releases
const release =
releases.find((r: any) => r.status === "Official") ||
releases[0];
// Step 2: Get full release details with recordings
const releaseResponse = await this.client.get(
`/release/${release.id}`,
{
params: {
inc: "recordings",
fmt: "json",
},
}
);
const media = releaseResponse.data?.media || [];
const tracks: Array<{
title: string;
position?: number;
duration?: number;
}> = [];
for (const medium of media) {
for (const track of medium.tracks || []) {
tracks.push({
title: track.title || track.recording?.title,
position: track.position,
duration: track.length || track.recording?.length,
});
}
}
logger.debug(
`[MusicBrainz] Found ${tracks.length} tracks for release group ${rgMbid}`
);
return tracks;
} catch (error: any) {
logger.error(
`MusicBrainz getAlbumTracks error: ${error.message}`
);
return [];
}
});
}
} }
export const musicBrainzService = new MusicBrainzService(); export const musicBrainzService = new MusicBrainzService();
@@ -0,0 +1,421 @@
/**
* Notification Policy Service
*
* Intelligent notification filtering for download jobs.
* Suppresses intermediate failures during active retry cycles,
* only sending notifications for terminal states (completed/exhausted).
*
* State Machine: PENDING PROCESSING COMPLETED/EXHAUSTED
*
* Policy:
* - SUPPRESS: All failures during active retry window
* - SEND: Final success, permanent failure after retries exhausted
*/
import { logger } from "../utils/logger";
import { prisma } from "../utils/db";
interface NotificationDecision {
shouldNotify: boolean;
reason: string;
notificationType?: "download_complete" | "download_failed";
}
// Configuration constants
const DEFAULT_RETRY_WINDOW_MINUTES = 30;
const SUPPRESS_TRANSIENT_FAILURES = true;
// Failure classification patterns
const TRANSIENT_PATTERNS = [
"no sources found",
"no indexer results",
"no releases available",
"import failed",
"connection timeout",
"rate limited",
"temporarily unavailable",
"searching for alternative",
"download stuck",
];
const PERMANENT_PATTERNS = [
"all releases exhausted",
"all albums exhausted",
"artist not found",
"download cancelled",
"album not found in lidarr",
];
const CRITICAL_PATTERNS = [
"disk full",
"permission denied",
"lidarr unavailable",
"authentication failed",
"invalid api key",
];
type FailureClassification = "transient" | "permanent" | "critical";
class NotificationPolicyService {
/**
* Evaluate whether a notification should be sent for a download job.
*
* @param jobId - The download job ID
* @param eventType - The type of event (complete, failed, retry, timeout)
* @returns Decision on whether to send notification
*/
async evaluateNotification(
jobId: string,
eventType: "complete" | "failed" | "retry" | "timeout"
): Promise<NotificationDecision> {
logger.debug(
`[NOTIFICATION-POLICY] Evaluating: ${jobId} (${eventType})`
);
// Fetch job with current state
const job = await prisma.downloadJob.findUnique({
where: { id: jobId },
});
if (!job) {
return {
shouldNotify: false,
reason: "Job not found",
};
}
const metadata = (job.metadata as any) || {};
const downloadType = metadata.downloadType || "library";
// Discovery and Spotify Import jobs never send individual notifications
// (they send batch notifications instead)
if (downloadType === "discovery" || metadata.spotifyImportJobId) {
return {
shouldNotify: false,
reason: `${downloadType} download - batch notification only`,
};
}
// Check if notification already sent for this job
if (metadata.notificationSent === true) {
return {
shouldNotify: false,
reason: "Notification already sent for this job",
};
}
// Handle based on job status
switch (job.status) {
case "completed":
return await this.evaluateCompletedJob(job, eventType);
case "processing":
return await this.evaluateProcessingJob(job, eventType);
case "failed":
case "exhausted":
return await this.evaluateFailedJob(job, eventType);
case "pending":
return {
shouldNotify: false,
reason: "Job not started yet",
};
default:
return {
shouldNotify: false,
reason: `Unknown status: ${job.status}`,
};
}
}
/**
* Evaluate notification for completed job
*/
private async evaluateCompletedJob(
job: any,
eventType: string
): Promise<NotificationDecision> {
if (eventType !== "complete") {
return {
shouldNotify: false,
reason: "Invalid event type for completed job",
};
}
// Check if another job for same album already notified
const hasOtherNotification = await this.hasAlreadyNotified(job);
if (hasOtherNotification) {
return {
shouldNotify: false,
reason: "Another job for same album already sent notification",
};
}
return {
shouldNotify: true,
reason: "Download completed successfully",
notificationType: "download_complete",
};
}
/**
* Evaluate notification for processing job
*/
private async evaluateProcessingJob(
job: any,
eventType: string
): Promise<NotificationDecision> {
// Processing jobs should never send notifications
// They're still in active retry window
if (eventType === "complete") {
return {
shouldNotify: false,
reason: "Job still processing - wait for status update to completed",
};
}
if (eventType === "failed" || eventType === "retry") {
// Check if in retry window
const inRetryWindow = await this.isInRetryWindow(job);
if (inRetryWindow) {
return {
shouldNotify: false,
reason: "Job in active retry window - suppressing notification",
};
}
// Retry window expired but still processing - extend it
return {
shouldNotify: false,
reason: "Retry window expired but job still processing - extending timeout",
};
}
if (eventType === "timeout") {
const inRetryWindow = await this.isInRetryWindow(job);
if (inRetryWindow) {
return {
shouldNotify: false,
reason: "Still in retry window - extending timeout",
};
}
// Timeout expired and out of retry window - let caller handle failure
return {
shouldNotify: false,
reason: "Timeout expired - caller should mark as failed",
};
}
return {
shouldNotify: false,
reason: "Processing job - no notification needed",
};
}
/**
* Evaluate notification for failed/exhausted job
*/
private async evaluateFailedJob(
job: any,
eventType: string
): Promise<NotificationDecision> {
if (eventType !== "failed" && eventType !== "timeout") {
return {
shouldNotify: false,
reason: "Invalid event type for failed job",
};
}
// Check if another job for same album already notified
const hasOtherNotification = await this.hasAlreadyNotified(job);
if (hasOtherNotification) {
return {
shouldNotify: false,
reason: "Another job for same album already sent notification",
};
}
// Classify the failure
const classification = this.classifyFailure(
job,
job.error || "Unknown error"
);
// Critical errors always notify
if (classification === "critical") {
return {
shouldNotify: true,
reason: "Critical error requires user intervention",
notificationType: "download_failed",
};
}
// Transient failures - suppress if configured
if (classification === "transient" && SUPPRESS_TRANSIENT_FAILURES) {
return {
shouldNotify: false,
reason: "Transient failure - suppressed (may succeed on retry)",
};
}
// Permanent failures or transient with suppress disabled
return {
shouldNotify: true,
reason:
classification === "permanent"
? "Permanent failure after retries exhausted"
: "Failure notification (transient suppression disabled)",
notificationType: "download_failed",
};
}
/**
* Check if job is in active retry window
* A job is in retry window if:
* 1. Status is 'processing'
* 2. Started within the last RETRY_WINDOW_MINUTES
*/
private async isInRetryWindow(job: any): Promise<boolean> {
if (job.status !== "processing") {
return false;
}
const metadata = (job.metadata as any) || {};
// Get retry window duration (configurable per job or use default)
const retryWindowMinutes =
metadata.retryWindowMinutes || DEFAULT_RETRY_WINDOW_MINUTES;
// Get start time
const startedAt = metadata.startedAt
? new Date(metadata.startedAt)
: job.createdAt;
// Calculate if window has expired
const windowMs = retryWindowMinutes * 60 * 1000;
const elapsed = Date.now() - startedAt.getTime();
if (elapsed > windowMs) {
logger.debug(
`[NOTIFICATION-POLICY] Retry window expired (${Math.round(
elapsed / 60000
)}m > ${retryWindowMinutes}m)`
);
return false;
}
logger.debug(
`[NOTIFICATION-POLICY] In retry window (${Math.round(
elapsed / 60000
)}m < ${retryWindowMinutes}m)`
);
return true;
}
/**
* Check if another job for the same artist+album has already sent a notification
* Prevents duplicate notifications when multiple jobs exist for same album
*/
private async hasAlreadyNotified(job: any): Promise<boolean> {
const metadata = (job.metadata as any) || {};
const artistName = metadata?.artistName?.toLowerCase().trim() || "";
const albumTitle = metadata?.albumTitle?.toLowerCase().trim() || "";
if (!artistName || !albumTitle) {
return false;
}
// Find other jobs for same album that have notified
const otherNotifiedJob = await prisma.downloadJob.findFirst({
where: {
id: { not: job.id },
userId: job.userId,
status: { in: ["completed", "failed", "exhausted"] },
},
});
if (otherNotifiedJob) {
const otherMeta = (otherNotifiedJob.metadata as any) || {};
const otherArtist =
otherMeta?.artistName?.toLowerCase().trim() || "";
const otherAlbum =
otherMeta?.albumTitle?.toLowerCase().trim() || "";
// Check if same album and notification was sent
if (
otherArtist === artistName &&
otherAlbum === albumTitle &&
otherMeta?.notificationSent === true
) {
logger.debug(
`[NOTIFICATION-POLICY] Found duplicate notification in job ${otherNotifiedJob.id}`
);
return true;
}
}
return false;
}
/**
* Classify failure type based on error message
* @returns 'transient' | 'permanent' | 'critical'
*/
private classifyFailure(job: any, error: string): FailureClassification {
const errorLower = error.toLowerCase();
// Check critical patterns first
for (const pattern of CRITICAL_PATTERNS) {
if (errorLower.includes(pattern)) {
logger.debug(
`[NOTIFICATION-POLICY] Classified as CRITICAL: ${pattern}`
);
return "critical";
}
}
// Check permanent patterns
for (const pattern of PERMANENT_PATTERNS) {
if (errorLower.includes(pattern)) {
logger.debug(
`[NOTIFICATION-POLICY] Classified as PERMANENT: ${pattern}`
);
return "permanent";
}
}
// Check transient patterns
for (const pattern of TRANSIENT_PATTERNS) {
if (errorLower.includes(pattern)) {
logger.debug(
`[NOTIFICATION-POLICY] Classified as TRANSIENT: ${pattern}`
);
return "transient";
}
}
// Default to transient if unknown
logger.debug(
`[NOTIFICATION-POLICY] Classified as TRANSIENT (default)`
);
return "transient";
}
/**
* Get configuration for notification policy
* Can be extended to pull from user settings or system config
*/
getConfig(): {
retryWindowMinutes: number;
suppressTransientFailures: boolean;
} {
return {
retryWindowMinutes: DEFAULT_RETRY_WINDOW_MINUTES,
suppressTransientFailures: SUPPRESS_TRANSIENT_FAILURES,
};
}
}
// Singleton instance
export const notificationPolicyService = new NotificationPolicyService();
+3 -2
View File
@@ -1,4 +1,5 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -35,7 +36,7 @@ class NotificationService {
}, },
}); });
console.log( logger.debug(
`[NOTIFICATION] Created: ${type} - ${title} for user ${userId}` `[NOTIFICATION] Created: ${type} - ${title} for user ${userId}`
); );
return notification; return notification;
@@ -124,7 +125,7 @@ class NotificationService {
}); });
if (result.count > 0) { if (result.count > 0) {
console.log( logger.debug(
`[NOTIFICATION] Cleaned up ${result.count} old notifications` `[NOTIFICATION] Cleaned up ${result.count} old notifications`
); );
} }
+4 -3
View File
@@ -1,4 +1,5 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { logger } from "../utils/logger";
import { config } from "../config"; import { config } from "../config";
interface PlaylistTrack { interface PlaylistTrack {
@@ -131,14 +132,14 @@ Return ONLY valid JSON, no markdown formatting.`;
return result.tracks || []; return result.tracks || [];
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
"OpenAI API error:", "OpenAI API error:",
error.response?.data || error.message error.response?.data || error.message
); );
// Log the raw response content for debugging // Log the raw response content for debugging
if (error instanceof SyntaxError) { if (error instanceof SyntaxError) {
console.error("Failed to parse JSON response"); logger.error("Failed to parse JSON response");
} }
throw new Error("Failed to generate playlist with AI"); throw new Error("Failed to generate playlist with AI");
@@ -175,7 +176,7 @@ Be concise and engaging (max 15 words).`;
return response.data.choices[0].message.content.trim(); return response.data.choices[0].message.content.trim();
} catch (error) { } catch (error) {
console.error("OpenAI enhancement error:", error); logger.error("OpenAI enhancement error:", error);
return "Recommended based on your listening history"; return "Recommended based on your listening history";
} }
} }
+21 -20
View File
@@ -1,4 +1,5 @@
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import { config } from "../config"; import { config } from "../config";
@@ -40,7 +41,7 @@ export class PodcastCacheService {
}; };
try { try {
console.log(" Starting podcast cover sync..."); logger.debug(" Starting podcast cover sync...");
// Ensure cover cache directory exists // Ensure cover cache directory exists
await fs.mkdir(this.coverCacheDir, { recursive: true }); await fs.mkdir(this.coverCacheDir, { recursive: true });
@@ -53,7 +54,7 @@ export class PodcastCacheService {
}, },
}); });
console.log( logger.debug(
`[PODCAST] Found ${podcasts.length} podcasts needing cover sync` `[PODCAST] Found ${podcasts.length} podcasts needing cover sync`
); );
@@ -72,7 +73,7 @@ export class PodcastCacheService {
data: { localCoverPath: localPath }, data: { localCoverPath: localPath },
}); });
result.synced++; result.synced++;
console.log(` Synced cover for: ${podcast.title}`); logger.debug(` Synced cover for: ${podcast.title}`);
} else { } else {
result.skipped++; result.skipped++;
} }
@@ -81,18 +82,18 @@ export class PodcastCacheService {
result.failed++; result.failed++;
const errorMsg = `Failed to sync cover for ${podcast.title}: ${error.message}`; const errorMsg = `Failed to sync cover for ${podcast.title}: ${error.message}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
console.error(` ${errorMsg}`); logger.error(` ${errorMsg}`);
} }
} }
console.log("\nPodcast Cover Sync Summary:"); logger.debug("\nPodcast Cover Sync Summary:");
console.log(` Synced: ${result.synced}`); logger.debug(` Synced: ${result.synced}`);
console.log(` Failed: ${result.failed}`); logger.debug(` Failed: ${result.failed}`);
console.log(` Skipped: ${result.skipped}`); logger.debug(` Skipped: ${result.skipped}`);
return result; return result;
} catch (error: any) { } catch (error: any) {
console.error(" Podcast cover sync failed:", error); logger.error(" Podcast cover sync failed:", error);
throw error; throw error;
} }
} }
@@ -109,7 +110,7 @@ export class PodcastCacheService {
}; };
try { try {
console.log(" Starting podcast episode cover sync..."); logger.debug(" Starting podcast episode cover sync...");
await fs.mkdir(this.coverCacheDir, { recursive: true }); await fs.mkdir(this.coverCacheDir, { recursive: true });
@@ -133,7 +134,7 @@ export class PodcastCacheService {
(ep) => ep.imageUrl !== ep.podcast.imageUrl (ep) => ep.imageUrl !== ep.podcast.imageUrl
); );
console.log( logger.debug(
`[PODCAST] Found ${uniqueEpisodes.length} episodes with unique covers` `[PODCAST] Found ${uniqueEpisodes.length} episodes with unique covers`
); );
@@ -152,7 +153,7 @@ export class PodcastCacheService {
data: { localCoverPath: localPath }, data: { localCoverPath: localPath },
}); });
result.synced++; result.synced++;
console.log( logger.debug(
` Synced cover for episode: ${episode.title}` ` Synced cover for episode: ${episode.title}`
); );
} else { } else {
@@ -163,18 +164,18 @@ export class PodcastCacheService {
result.failed++; result.failed++;
const errorMsg = `Failed to sync cover for episode ${episode.title}: ${error.message}`; const errorMsg = `Failed to sync cover for episode ${episode.title}: ${error.message}`;
result.errors.push(errorMsg); result.errors.push(errorMsg);
console.error(` ${errorMsg}`); logger.error(` ${errorMsg}`);
} }
} }
console.log("\nEpisode Cover Sync Summary:"); logger.debug("\nEpisode Cover Sync Summary:");
console.log(` Synced: ${result.synced}`); logger.debug(` Synced: ${result.synced}`);
console.log(` Failed: ${result.failed}`); logger.debug(` Failed: ${result.failed}`);
console.log(` Skipped: ${result.skipped}`); logger.debug(` Skipped: ${result.skipped}`);
return result; return result;
} catch (error: any) { } catch (error: any) {
console.error(" Episode cover sync failed:", error); logger.error(" Episode cover sync failed:", error);
throw error; throw error;
} }
} }
@@ -204,7 +205,7 @@ export class PodcastCacheService {
return filePath; return filePath;
} catch (error: any) { } catch (error: any) {
console.error( logger.error(
`Failed to download cover for ${type} ${id}:`, `Failed to download cover for ${type} ${id}:`,
error.message error.message
); );
@@ -240,7 +241,7 @@ export class PodcastCacheService {
if (!validCoverPaths.has(file)) { if (!validCoverPaths.has(file)) {
await fs.unlink(path.join(this.coverCacheDir, file)); await fs.unlink(path.join(this.coverCacheDir, file));
deleted++; deleted++;
console.log(` [DELETE] Deleted orphaned podcast cover: ${file}`); logger.debug(` [DELETE] Deleted orphaned podcast cover: ${file}`);
} }
} }
+19 -18
View File
@@ -1,4 +1,5 @@
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import { config } from "../config"; import { config } from "../config";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@@ -53,7 +54,7 @@ export function getDownloadProgress(episodeId: string): { progress: number; down
export async function getCachedFilePath(episodeId: string): Promise<string | null> { export async function getCachedFilePath(episodeId: string): Promise<string | null> {
// Don't return cache path if still downloading - file may be incomplete // Don't return cache path if still downloading - file may be incomplete
if (downloadingEpisodes.has(episodeId)) { if (downloadingEpisodes.has(episodeId)) {
console.log(`[PODCAST-DL] Episode ${episodeId} is still downloading, not using cache`); logger.debug(`[PODCAST-DL] Episode ${episodeId} is still downloading, not using cache`);
return null; return null;
} }
@@ -78,7 +79,7 @@ export async function getCachedFilePath(episodeId: string): Promise<string | nul
const actual = stats.size; const actual = stats.size;
const variance = Math.abs(actual - expected) / expected; const variance = Math.abs(actual - expected) / expected;
if (variance > 0.01) { if (variance > 0.01) {
console.log( logger.debug(
`[PODCAST-DL] Episode size mismatch vs episode.fileSize for ${episodeId}: actual ${actual} vs expected ${expected} (variance ${Math.round( `[PODCAST-DL] Episode size mismatch vs episode.fileSize for ${episodeId}: actual ${actual} vs expected ${expected} (variance ${Math.round(
variance * 100 variance * 100
)}%), deleting cache` )}%), deleting cache`
@@ -101,7 +102,7 @@ export async function getCachedFilePath(episodeId: string): Promise<string | nul
// If no DB record, file might be incomplete or stale // If no DB record, file might be incomplete or stale
if (!dbRecord) { if (!dbRecord) {
console.log(`[PODCAST-DL] No DB record for ${episodeId}, deleting stale cache file`); logger.debug(`[PODCAST-DL] No DB record for ${episodeId}, deleting stale cache file`);
await fs.unlink(cachedPath).catch(() => {}); await fs.unlink(cachedPath).catch(() => {});
return null; return null;
} }
@@ -112,7 +113,7 @@ export async function getCachedFilePath(episodeId: string): Promise<string | nul
const variance = Math.abs(actualSize - expectedSize) / expectedSize; const variance = Math.abs(actualSize - expectedSize) / expectedSize;
if (expectedSize > 0 && variance > 0.01) { if (expectedSize > 0 && variance > 0.01) {
console.log(`[PODCAST-DL] Size mismatch for ${episodeId}: actual ${actualSize} vs expected ${Math.round(expectedSize)}, deleting`); logger.debug(`[PODCAST-DL] Size mismatch for ${episodeId}: actual ${actualSize} vs expected ${Math.round(expectedSize)}, deleting`);
await fs.unlink(cachedPath).catch(() => {}); await fs.unlink(cachedPath).catch(() => {});
await prisma.podcastDownload.deleteMany({ where: { episodeId } }); await prisma.podcastDownload.deleteMany({ where: { episodeId } });
return null; return null;
@@ -124,7 +125,7 @@ export async function getCachedFilePath(episodeId: string): Promise<string | nul
data: { lastAccessedAt: new Date() } data: { lastAccessedAt: new Date() }
}); });
console.log(`[PODCAST-DL] Cache valid for ${episodeId}: ${stats.size} bytes`); logger.debug(`[PODCAST-DL] Cache valid for ${episodeId}: ${stats.size} bytes`);
return cachedPath; return cachedPath;
} }
return null; return null;
@@ -144,7 +145,7 @@ export function downloadInBackground(
): void { ): void {
// Skip if already downloading // Skip if already downloading
if (downloadingEpisodes.has(episodeId)) { if (downloadingEpisodes.has(episodeId)) {
console.log(`[PODCAST-DL] Already downloading episode ${episodeId}, skipping`); logger.debug(`[PODCAST-DL] Already downloading episode ${episodeId}, skipping`);
return; return;
} }
@@ -154,7 +155,7 @@ export function downloadInBackground(
// Start download in background (don't await) // Start download in background (don't await)
performDownload(episodeId, audioUrl, userId) performDownload(episodeId, audioUrl, userId)
.catch(err => { .catch(err => {
console.error(`[PODCAST-DL] Background download failed for ${episodeId}:`, err.message); logger.error(`[PODCAST-DL] Background download failed for ${episodeId}:`, err.message);
}) })
.finally(() => { .finally(() => {
downloadingEpisodes.delete(episodeId); downloadingEpisodes.delete(episodeId);
@@ -171,7 +172,7 @@ async function performDownload(
attempt: number = 1 attempt: number = 1
): Promise<void> { ): Promise<void> {
const maxAttempts = 3; const maxAttempts = 3;
console.log(`[PODCAST-DL] Starting background download for episode ${episodeId} (attempt ${attempt}/${maxAttempts})`); logger.debug(`[PODCAST-DL] Starting background download for episode ${episodeId} (attempt ${attempt}/${maxAttempts})`);
const cacheDir = getPodcastCacheDir(); const cacheDir = getPodcastCacheDir();
@@ -187,7 +188,7 @@ async function performDownload(
const existingCached = await getCachedFilePath(episodeId); const existingCached = await getCachedFilePath(episodeId);
downloadingEpisodes.add(episodeId); // Re-add downloadingEpisodes.add(episodeId); // Re-add
if (existingCached) { if (existingCached) {
console.log(`[PODCAST-DL] Episode ${episodeId} already cached, skipping download`); logger.debug(`[PODCAST-DL] Episode ${episodeId} already cached, skipping download`);
return; return;
} }
@@ -247,7 +248,7 @@ async function performDownload(
} catch {} } catch {}
} }
console.log( logger.debug(
`[PODCAST-DL] Downloading ${episodeId} (${expectedBytes > 0 ? Math.round(expectedBytes / 1024 / 1024) : 0}MB)` `[PODCAST-DL] Downloading ${episodeId} (${expectedBytes > 0 ? Math.round(expectedBytes / 1024 / 1024) : 0}MB)`
); );
@@ -271,7 +272,7 @@ async function performDownload(
const now = Date.now(); const now = Date.now();
if (now - lastLogTime > 30000) { if (now - lastLogTime > 30000) {
const percent = contentLength > 0 ? Math.round((bytesDownloaded / contentLength) * 100) : 0; const percent = contentLength > 0 ? Math.round((bytesDownloaded / contentLength) * 100) : 0;
console.log(`[PODCAST-DL] Download progress ${episodeId}: ${percent}% (${Math.round(bytesDownloaded / 1024 / 1024)}MB)`); logger.debug(`[PODCAST-DL] Download progress ${episodeId}: ${percent}% (${Math.round(bytesDownloaded / 1024 / 1024)}MB)`);
lastLogTime = now; lastLogTime = now;
} }
}); });
@@ -312,7 +313,7 @@ async function performDownload(
const variance = Math.abs(stats.size - expectedBytes) / expectedBytes; const variance = Math.abs(stats.size - expectedBytes) / expectedBytes;
if (variance > 0.01) { if (variance > 0.01) {
const percentComplete = Math.round((stats.size / expectedBytes) * 100); const percentComplete = Math.round((stats.size / expectedBytes) * 100);
console.error(`[PODCAST-DL] Incomplete download for ${episodeId}: ${stats.size}/${expectedBytes} bytes (${percentComplete}%)`); logger.error(`[PODCAST-DL] Incomplete download for ${episodeId}: ${stats.size}/${expectedBytes} bytes (${percentComplete}%)`);
await fs.unlink(tempPath).catch(() => {}); await fs.unlink(tempPath).catch(() => {});
throw new Error(`Download incomplete: got ${stats.size} bytes, expected ${expectedBytes}`); throw new Error(`Download incomplete: got ${stats.size} bytes, expected ${expectedBytes}`);
} }
@@ -344,7 +345,7 @@ async function performDownload(
} }
}); });
console.log(`[PODCAST-DL] Successfully cached episode ${episodeId} (${fileSizeMb.toFixed(1)}MB)`); logger.debug(`[PODCAST-DL] Successfully cached episode ${episodeId} (${fileSizeMb.toFixed(1)}MB)`);
// Clean up progress tracking // Clean up progress tracking
downloadProgress.delete(episodeId); downloadProgress.delete(episodeId);
@@ -356,7 +357,7 @@ async function performDownload(
// Retry on failure // Retry on failure
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
console.log(`[PODCAST-DL] Download failed (attempt ${attempt}), retrying in 5s: ${error.message}`); logger.debug(`[PODCAST-DL] Download failed (attempt ${attempt}), retrying in 5s: ${error.message}`);
await new Promise(resolve => setTimeout(resolve, 5000)); await new Promise(resolve => setTimeout(resolve, 5000));
return performDownload(episodeId, audioUrl, userId, attempt + 1); return performDownload(episodeId, audioUrl, userId, attempt + 1);
} }
@@ -370,7 +371,7 @@ async function performDownload(
* Should be called periodically (e.g., daily) * Should be called periodically (e.g., daily)
*/ */
export async function cleanupExpiredCache(): Promise<{ deleted: number; freedMb: number }> { export async function cleanupExpiredCache(): Promise<{ deleted: number; freedMb: number }> {
console.log('[PODCAST-DL] Starting cache cleanup...'); logger.debug('[PODCAST-DL] Starting cache cleanup...');
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
@@ -398,13 +399,13 @@ export async function cleanupExpiredCache(): Promise<{ deleted: number; freedMb:
deleted++; deleted++;
freedMb += download.fileSizeMb; freedMb += download.fileSizeMb;
console.log(`[PODCAST-DL] Deleted expired cache: ${path.basename(download.localPath)}`); logger.debug(`[PODCAST-DL] Deleted expired cache: ${path.basename(download.localPath)}`);
} catch (err: any) { } catch (err: any) {
console.error(`[PODCAST-DL] Failed to delete ${download.localPath}:`, err.message); logger.error(`[PODCAST-DL] Failed to delete ${download.localPath}:`, err.message);
} }
} }
console.log(`[PODCAST-DL] Cleanup complete: ${deleted} files deleted, ${freedMb.toFixed(1)}MB freed`); logger.debug(`[PODCAST-DL] Cleanup complete: ${deleted} files deleted, ${freedMb.toFixed(1)}MB freed`);
return { deleted, freedMb }; return { deleted, freedMb };
} }
+155 -98
View File
@@ -1,6 +1,12 @@
import { prisma } from "../utils/db"; import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
import { lastFmService } from "./lastfm"; import { lastFmService } from "./lastfm";
import { moodBucketService } from "./moodBucketService"; import { moodBucketService } from "./moodBucketService";
import {
getDecadeWhereClause,
getEffectiveYear,
getDecadeFromYear,
} from "../utils/dateFilters";
export interface ProgrammaticMix { export interface ProgrammaticMix {
id: string; id: string;
@@ -109,10 +115,14 @@ function getMixColor(type: string): string {
return MIX_COLORS[type] || MIX_COLORS["default"]; return MIX_COLORS[type] || MIX_COLORS["default"];
} }
// Helper to randomly sample from array // Helper to randomly sample from array using Fisher-Yates shuffle
function randomSample<T>(array: T[], count: number): T[] { function randomSample<T>(array: T[], count: number): T[] {
const shuffled = [...array].sort(() => Math.random() - 0.5); const result = [...array];
return shuffled.slice(0, count); for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result.slice(0, count);
} }
// Helper to get seeded random number for daily consistency // Helper to get seeded random number for daily consistency
@@ -129,7 +139,14 @@ function getSeededRandom(seed: string): number {
// Type for track with album cover // Type for track with album cover
type TrackWithAlbumCover = { type TrackWithAlbumCover = {
id: string; id: string;
album: { coverUrl: string | null; genres?: unknown }; album: {
coverUrl: string | null;
genres?: unknown;
userGenres?: string[] | null;
artist?: {
userGenres?: string[] | null;
};
};
lastfmTags?: string[]; lastfmTags?: string[];
essentiaGenres?: string[]; essentiaGenres?: string[];
[key: string]: unknown; [key: string]: unknown;
@@ -154,30 +171,71 @@ async function findTracksByGenrePatterns(
{ essentiaGenres: { hasSome: tagPatterns } }, { essentiaGenres: { hasSome: tagPatterns } },
], ],
}, },
include: { album: { select: { coverUrl: true, genres: true } } }, include: {
album: {
select: {
coverUrl: true,
genres: true,
userGenres: true,
artist: {
select: {
userGenres: true,
},
},
},
},
},
take: limit, take: limit,
}); });
if (tracks.length >= 15) { if (tracks.length >= 15) {
return tracks; return tracks as TrackWithAlbumCover[];
} }
// Strategy 2: Query albums with non-empty genres and filter in memory // Strategy 2: Query albums with non-empty genres (canonical or user) and filter in memory
const albumTracks = await prisma.track.findMany({ const albumTracks = await prisma.track.findMany({
where: { where: {
album: { album: {
genres: { not: { equals: null } }, OR: [
{ genres: { not: { equals: null } } },
{ userGenres: { not: { equals: null } } },
],
},
},
include: {
album: {
select: {
coverUrl: true,
genres: true,
userGenres: true,
artist: {
select: {
userGenres: true,
},
},
},
}, },
}, },
include: { album: { select: { coverUrl: true, genres: true } } },
take: limit * 3, // Get more to filter down take: limit * 3, // Get more to filter down
}); });
// Filter by genre patterns (case-insensitive partial match) // Filter by genre patterns (case-insensitive partial match)
// Merge canonical and user genres from both album and artist
const genreMatched = albumTracks.filter((t) => { const genreMatched = albumTracks.filter((t) => {
const albumGenres = t.album.genres as string[] | null; const albumGenres = t.album.genres as string[] | null;
if (!albumGenres || !Array.isArray(albumGenres)) return false; const albumUserGenres = (t.album.userGenres as string[] | null) || [];
return albumGenres.some((ag) => const artistUserGenres = (t.album.artist?.userGenres as string[] | null) || [];
// Merge all genres
const allGenres = [
...(albumGenres || []),
...albumUserGenres,
...artistUserGenres,
];
if (allGenres.length === 0) return false;
return allGenres.some((ag) =>
genrePatterns.some((gp) => genrePatterns.some((gp) =>
ag.toLowerCase().includes(gp.toLowerCase()) ag.toLowerCase().includes(gp.toLowerCase())
) )
@@ -191,7 +249,7 @@ async function findTracksByGenrePatterns(
...genreMatched.filter((t) => !existingIds.has(t.id)), ...genreMatched.filter((t) => !existingIds.has(t.id)),
]; ];
return merged.slice(0, limit); return merged.slice(0, limit) as TrackWithAlbumCover[];
} }
export class ProgrammaticPlaylistService { export class ProgrammaticPlaylistService {
@@ -218,7 +276,7 @@ export class ProgrammaticPlaylistService {
: `${today}-${userId}`; : `${today}-${userId}`;
const dateSeed = getSeededRandom(seedString); const dateSeed = getSeededRandom(seedString);
console.log( logger.debug(
`[MIXES] Generating mixes for user ${userId}, forceRandom: ${forceRandom}, seed: ${dateSeed}` `[MIXES] Generating mixes for user ${userId}, forceRandom: ${forceRandom}, seed: ${dateSeed}`
); );
@@ -444,7 +502,7 @@ export class ProgrammaticPlaylistService {
const selectedIndices: number[] = []; const selectedIndices: number[] = [];
let seed = dateSeed; let seed = dateSeed;
console.log( logger.debug(
`[MIXES] Selecting ${this.DAILY_MIX_COUNT} mixes from ${mixGenerators.length} types...` `[MIXES] Selecting ${this.DAILY_MIX_COUNT} mixes from ${mixGenerators.length} types...`
); );
@@ -453,33 +511,33 @@ export class ProgrammaticPlaylistService {
const index = seed % mixGenerators.length; const index = seed % mixGenerators.length;
if (!selectedIndices.includes(index)) { if (!selectedIndices.includes(index)) {
selectedIndices.push(index); selectedIndices.push(index);
console.log( logger.debug(
`[MIXES] Selected index ${index}: ${mixGenerators[index].name}` `[MIXES] Selected index ${index}: ${mixGenerators[index].name}`
); );
} }
} }
console.log( logger.debug(
`[MIXES] Final selected indices: [${selectedIndices.join(", ")}]` `[MIXES] Final selected indices: [${selectedIndices.join(", ")}]`
); );
// Generate selected mixes // Generate selected mixes
const mixPromises = selectedIndices.map((i) => { const mixPromises = selectedIndices.map((i) => {
console.log(`[MIXES] Generating ${mixGenerators[i].name}...`); logger.debug(`[MIXES] Generating ${mixGenerators[i].name}...`);
return mixGenerators[i].fn(); return mixGenerators[i].fn();
}); });
const mixes = await Promise.all(mixPromises); const mixes = await Promise.all(mixPromises);
console.log(`[MIXES] Generated ${mixes.length} mixes before filtering`); logger.debug(`[MIXES] Generated ${mixes.length} mixes before filtering`);
mixes.forEach((mix, i) => { mixes.forEach((mix, i) => {
if (mix === null) { if (mix === null) {
console.log( logger.debug(
`[MIXES] Mix ${i} (${ `[MIXES] Mix ${i} (${
mixGenerators[selectedIndices[i]].name mixGenerators[selectedIndices[i]].name
}) returned NULL` }) returned NULL`
); );
} else { } else {
console.log( logger.debug(
`[MIXES] Mix ${i}: ${mix.name} (${mix.trackCount} tracks)` `[MIXES] Mix ${i}: ${mix.name} (${mix.trackCount} tracks)`
); );
} }
@@ -489,13 +547,13 @@ export class ProgrammaticPlaylistService {
let finalMixes = mixes.filter( let finalMixes = mixes.filter(
(mix): mix is ProgrammaticMix => mix !== null (mix): mix is ProgrammaticMix => mix !== null
); );
console.log( logger.debug(
`[MIXES] Returning ${finalMixes.length} mixes after filtering nulls` `[MIXES] Returning ${finalMixes.length} mixes after filtering nulls`
); );
// If we don't have 5 mixes, try to fill gaps with successful generators // If we don't have 5 mixes, try to fill gaps with successful generators
if (finalMixes.length < this.DAILY_MIX_COUNT) { if (finalMixes.length < this.DAILY_MIX_COUNT) {
console.log( logger.debug(
`[MIXES] Only got ${finalMixes.length} mixes, trying to fill gaps...` `[MIXES] Only got ${finalMixes.length} mixes, trying to fill gaps...`
); );
@@ -510,34 +568,34 @@ export class ProgrammaticPlaylistService {
i++ i++
) { ) {
if (!attemptedIndices.has(i)) { if (!attemptedIndices.has(i)) {
console.log( logger.debug(
`[MIXES] Attempting fallback: ${mixGenerators[i].name}` `[MIXES] Attempting fallback: ${mixGenerators[i].name}`
); );
const fallbackMix = await mixGenerators[i].fn(); const fallbackMix = await mixGenerators[i].fn();
if (fallbackMix && !successfulTypes.has(fallbackMix.type)) { if (fallbackMix && !successfulTypes.has(fallbackMix.type)) {
finalMixes.push(fallbackMix); finalMixes.push(fallbackMix);
successfulTypes.add(fallbackMix.type); successfulTypes.add(fallbackMix.type);
console.log( logger.debug(
`[MIXES] Fallback succeeded: ${fallbackMix.name}` `[MIXES] Fallback succeeded: ${fallbackMix.name}`
); );
} }
} }
} }
console.log(`[MIXES] After fallbacks: ${finalMixes.length} mixes`); logger.debug(`[MIXES] After fallbacks: ${finalMixes.length} mixes`);
} }
// Check if user has saved mood mix from the new bucket system (fast lookup) // Check if user has saved mood mix from the new bucket system (fast lookup)
try { try {
const savedMoodMix = await moodBucketService.getUserMoodMix(userId); const savedMoodMix = await moodBucketService.getUserMoodMix(userId);
if (savedMoodMix) { if (savedMoodMix) {
console.log( logger.debug(
`[MIXES] User has saved mood mix: "${savedMoodMix.name}" with ${savedMoodMix.trackCount} tracks` `[MIXES] User has saved mood mix: "${savedMoodMix.name}" with ${savedMoodMix.trackCount} tracks`
); );
finalMixes.push(savedMoodMix); finalMixes.push(savedMoodMix);
} }
} catch (err) { } catch (err) {
console.error("[MIXES] Error getting user's saved mood mix:", err); logger.error("[MIXES] Error getting user's saved mood mix:", err);
} }
return finalMixes; return finalMixes;
@@ -553,13 +611,14 @@ export class ProgrammaticPlaylistService {
// Get all decades // Get all decades
const albums = await prisma.album.findMany({ const albums = await prisma.album.findMany({
where: { tracks: { some: {} } }, where: { tracks: { some: {} } },
select: { year: true }, select: { year: true, originalYear: true, displayYear: true },
}); });
const decades = new Set<number>(); const decades = new Set<number>();
albums.forEach((album) => { albums.forEach((album) => {
if (album.year) { const effectiveYear = getEffectiveYear(album);
const decade = Math.floor(album.year / 10) * 10; if (effectiveYear) {
const decade = getDecadeFromYear(effectiveYear);
decades.add(decade); decades.add(decade);
} }
}); });
@@ -574,9 +633,7 @@ export class ProgrammaticPlaylistService {
// Get ALL tracks from this decade // Get ALL tracks from this decade
const tracks = await prisma.track.findMany({ const tracks = await prisma.track.findMany({
where: { where: {
album: { album: getDecadeWhereClause(selectedDecade),
year: { gte: selectedDecade, lt: selectedDecade + 10 },
},
}, },
include: { include: {
album: { select: { coverUrl: true } }, album: { select: { coverUrl: true } },
@@ -622,13 +679,13 @@ export class ProgrammaticPlaylistService {
take: 20, take: 20,
}); });
console.log(`[GENRE MIX] Found ${genres.length} genres total`); logger.debug(`[GENRE MIX] Found ${genres.length} genres total`);
const validGenres = genres.filter((g) => g._count.trackGenres >= 5); const validGenres = genres.filter((g) => g._count.trackGenres >= 5);
console.log( logger.debug(
`[GENRE MIX] ${validGenres.length} genres have >= 5 tracks` `[GENRE MIX] ${validGenres.length} genres have >= 5 tracks`
); );
if (validGenres.length === 0) { if (validGenres.length === 0) {
console.log(`[GENRE MIX] FAILED: No genres with enough tracks`); logger.debug(`[GENRE MIX] FAILED: No genres with enough tracks`);
return null; return null;
} }
@@ -684,11 +741,11 @@ export class ProgrammaticPlaylistService {
take: this.TRACK_LIMIT, take: this.TRACK_LIMIT,
}); });
console.log( logger.debug(
`[TOP TRACKS MIX] Found ${playStats.length} unique played tracks` `[TOP TRACKS MIX] Found ${playStats.length} unique played tracks`
); );
if (playStats.length < 5) { if (playStats.length < 5) {
console.log( logger.debug(
`[TOP TRACKS MIX] FAILED: Only ${playStats.length} tracks (need at least 5)` `[TOP TRACKS MIX] FAILED: Only ${playStats.length} tracks (need at least 5)`
); );
return null; return null;
@@ -796,11 +853,11 @@ export class ProgrammaticPlaylistService {
}, },
}); });
console.log( logger.debug(
`[ARTIST SIMILAR MIX] Found ${recentPlays.length} plays in last 7 days` `[ARTIST SIMILAR MIX] Found ${recentPlays.length} plays in last 7 days`
); );
if (recentPlays.length === 0) { if (recentPlays.length === 0) {
console.log(`[ARTIST SIMILAR MIX] FAILED: No plays in last 7 days`); logger.debug(`[ARTIST SIMILAR MIX] FAILED: No plays in last 7 days`);
return null; return null;
} }
@@ -824,13 +881,13 @@ export class ProgrammaticPlaylistService {
}); });
if (!topArtist || !topArtist.name) { if (!topArtist || !topArtist.name) {
console.log( logger.debug(
`[ARTIST SIMILAR MIX] FAILED: Top artist not found or has no name` `[ARTIST SIMILAR MIX] FAILED: Top artist not found or has no name`
); );
return null; return null;
} }
console.log(`[ARTIST SIMILAR MIX] Top artist: ${topArtist.name}`); logger.debug(`[ARTIST SIMILAR MIX] Top artist: ${topArtist.name}`);
// Get similar artists from Last.fm // Get similar artists from Last.fm
try { try {
@@ -839,7 +896,7 @@ export class ProgrammaticPlaylistService {
"10" "10"
); );
console.log( logger.debug(
`[ARTIST SIMILAR MIX] Last.fm returned ${similarArtists.length} similar artists` `[ARTIST SIMILAR MIX] Last.fm returned ${similarArtists.length} similar artists`
); );
@@ -859,7 +916,7 @@ export class ProgrammaticPlaylistService {
}, },
}); });
console.log( logger.debug(
`[ARTIST SIMILAR MIX] Found ${artistsInLibrary.length} similar artists in library` `[ARTIST SIMILAR MIX] Found ${artistsInLibrary.length} similar artists in library`
); );
@@ -867,12 +924,12 @@ export class ProgrammaticPlaylistService {
artist.albums.flatMap((album) => album.tracks) artist.albums.flatMap((album) => album.tracks)
); );
console.log( logger.debug(
`[ARTIST SIMILAR MIX] Total tracks from similar artists: ${tracks.length}` `[ARTIST SIMILAR MIX] Total tracks from similar artists: ${tracks.length}`
); );
if (tracks.length < 5) { if (tracks.length < 5) {
console.log( logger.debug(
`[ARTIST SIMILAR MIX] FAILED: Only ${tracks.length} tracks (need at least 5)` `[ARTIST SIMILAR MIX] FAILED: Only ${tracks.length} tracks (need at least 5)`
); );
return null; return null;
@@ -895,7 +952,7 @@ export class ProgrammaticPlaylistService {
color: getMixColor("artist-similar"), color: getMixColor("artist-similar"),
}; };
} catch (error) { } catch (error) {
console.error("Failed to generate artist similar mix:", error); logger.error("Failed to generate artist similar mix:", error);
return null; return null;
} }
} }
@@ -994,7 +1051,7 @@ export class ProgrammaticPlaylistService {
}, },
}); });
tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track));
console.log( logger.debug(
`[PARTY MIX] Found ${tracks.length} tracks from Genre table` `[PARTY MIX] Found ${tracks.length} tracks from Genre table`
); );
@@ -1009,7 +1066,7 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[PARTY MIX] After album genre fallback: ${tracks.length} tracks` `[PARTY MIX] After album genre fallback: ${tracks.length} tracks`
); );
} }
@@ -1037,13 +1094,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...audioTracks.filter((t) => !existingIds.has(t.id)), ...audioTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[PARTY MIX] After audio analysis fallback: ${tracks.length} tracks` `[PARTY MIX] After audio analysis fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[PARTY MIX] FAILED: Only ${tracks.length} tracks found` `[PARTY MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -1099,11 +1156,11 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
console.log(`[CHILL MIX] Enhanced mode: Found ${tracks.length} tracks`); logger.debug(`[CHILL MIX] Enhanced mode: Found ${tracks.length} tracks`);
// Strategy 2: Standard mode fallback // Strategy 2: Standard mode fallback
if (tracks.length < this.MIN_TRACKS_DAILY) { if (tracks.length < this.MIN_TRACKS_DAILY) {
console.log(`[CHILL MIX] Falling back to Standard mode`); logger.debug(`[CHILL MIX] Falling back to Standard mode`);
tracks = await prisma.track.findMany({ tracks = await prisma.track.findMany({
where: { where: {
analysisStatus: "completed", analysisStatus: "completed",
@@ -1125,17 +1182,17 @@ export class ProgrammaticPlaylistService {
include: { album: { select: { coverUrl: true } } }, include: { album: { select: { coverUrl: true } } },
take: 100, take: 100,
}); });
console.log( logger.debug(
`[CHILL MIX] Standard mode: Found ${tracks.length} tracks` `[CHILL MIX] Standard mode: Found ${tracks.length} tracks`
); );
} }
console.log( logger.debug(
`[CHILL MIX] Total: ${tracks.length} tracks matching criteria` `[CHILL MIX] Total: ${tracks.length} tracks matching criteria`
); );
if (tracks.length < this.MIN_TRACKS_DAILY) { if (tracks.length < this.MIN_TRACKS_DAILY) {
console.log( logger.debug(
`[CHILL MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})` `[CHILL MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})`
); );
return null; return null;
@@ -1222,13 +1279,13 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = enhancedTracks; tracks = enhancedTracks;
console.log( logger.debug(
`[WORKOUT MIX] Enhanced mode: Found ${tracks.length} tracks` `[WORKOUT MIX] Enhanced mode: Found ${tracks.length} tracks`
); );
// Strategy 2: Standard mode fallback - audio analysis // Strategy 2: Standard mode fallback - audio analysis
if (tracks.length < 15) { if (tracks.length < 15) {
console.log(`[WORKOUT MIX] Falling back to Standard mode`); logger.debug(`[WORKOUT MIX] Falling back to Standard mode`);
const audioTracks = await prisma.track.findMany({ const audioTracks = await prisma.track.findMany({
where: { where: {
analysisStatus: "completed", analysisStatus: "completed",
@@ -1259,7 +1316,7 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...audioTracks.filter((t) => !existingIds.has(t.id)), ...audioTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[WORKOUT MIX] Standard mode: Total ${tracks.length} tracks` `[WORKOUT MIX] Standard mode: Total ${tracks.length} tracks`
); );
} }
@@ -1289,7 +1346,7 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...genreTracks.filter((t) => !existingIds.has(t.id)), ...genreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[WORKOUT MIX] After Genre table: ${tracks.length} tracks` `[WORKOUT MIX] After Genre table: ${tracks.length} tracks`
); );
} }
@@ -1305,13 +1362,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[WORKOUT MIX] After album genre fallback: ${tracks.length} tracks` `[WORKOUT MIX] After album genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[WORKOUT MIX] FAILED: Only ${tracks.length} tracks found` `[WORKOUT MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -1383,7 +1440,7 @@ export class ProgrammaticPlaylistService {
}, },
}); });
tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track)); tracks = genres.flatMap((g) => g.trackGenres.map((tg) => tg.track));
console.log( logger.debug(
`[FOCUS MIX] Found ${tracks.length} tracks from Genre table` `[FOCUS MIX] Found ${tracks.length} tracks from Genre table`
); );
@@ -1398,7 +1455,7 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[FOCUS MIX] After album genre fallback: ${tracks.length} tracks` `[FOCUS MIX] After album genre fallback: ${tracks.length} tracks`
); );
} }
@@ -1419,13 +1476,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...audioTracks.filter((t) => !existingIds.has(t.id)), ...audioTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[FOCUS MIX] After audio analysis fallback: ${tracks.length} tracks` `[FOCUS MIX] After audio analysis fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[FOCUS MIX] FAILED: Only ${tracks.length} tracks found` `[FOCUS MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -1482,7 +1539,7 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = audioTracks; tracks = audioTracks;
console.log( logger.debug(
`[HIGH ENERGY MIX] Found ${tracks.length} tracks from audio analysis` `[HIGH ENERGY MIX] Found ${tracks.length} tracks from audio analysis`
); );
@@ -1507,13 +1564,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[HIGH ENERGY MIX] After genre fallback: ${tracks.length} tracks` `[HIGH ENERGY MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[HIGH ENERGY MIX] FAILED: Only ${tracks.length} tracks found` `[HIGH ENERGY MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -1573,13 +1630,13 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
console.log( logger.debug(
`[LATE NIGHT MIX] Enhanced mode: Found ${tracks.length} tracks` `[LATE NIGHT MIX] Enhanced mode: Found ${tracks.length} tracks`
); );
// Fallback to Standard mode if not enough Enhanced tracks // Fallback to Standard mode if not enough Enhanced tracks
if (tracks.length < this.MIN_TRACKS_DAILY) { if (tracks.length < this.MIN_TRACKS_DAILY) {
console.log(`[LATE NIGHT MIX] Falling back to Standard mode`); logger.debug(`[LATE NIGHT MIX] Falling back to Standard mode`);
tracks = await prisma.track.findMany({ tracks = await prisma.track.findMany({
where: { where: {
analysisStatus: "completed", analysisStatus: "completed",
@@ -1601,18 +1658,18 @@ export class ProgrammaticPlaylistService {
include: { album: { select: { coverUrl: true } } }, include: { album: { select: { coverUrl: true } } },
take: 100, take: 100,
}); });
console.log( logger.debug(
`[LATE NIGHT MIX] Standard mode: Found ${tracks.length} tracks` `[LATE NIGHT MIX] Standard mode: Found ${tracks.length} tracks`
); );
} }
console.log( logger.debug(
`[LATE NIGHT MIX] Total: ${tracks.length} tracks matching criteria` `[LATE NIGHT MIX] Total: ${tracks.length} tracks matching criteria`
); );
// No fallback padding - if not enough truly mellow tracks, don't generate // No fallback padding - if not enough truly mellow tracks, don't generate
if (tracks.length < this.MIN_TRACKS_DAILY) { if (tracks.length < this.MIN_TRACKS_DAILY) {
console.log( logger.debug(
`[LATE NIGHT MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})` `[LATE NIGHT MIX] FAILED: Only ${tracks.length} tracks (need ${this.MIN_TRACKS_DAILY})`
); );
return null; return null;
@@ -1672,7 +1729,7 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = enhancedTracks; tracks = enhancedTracks;
console.log(`[HAPPY MIX] Enhanced mode: Found ${tracks.length} tracks`); logger.debug(`[HAPPY MIX] Enhanced mode: Found ${tracks.length} tracks`);
// Strategy 2: Standard mode fallback - valence/energy heuristics // Strategy 2: Standard mode fallback - valence/energy heuristics
if (tracks.length < 15) { if (tracks.length < 15) {
@@ -1690,7 +1747,7 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...standardTracks.filter((t) => !existingIds.has(t.id)), ...standardTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[HAPPY MIX] After Standard fallback: ${tracks.length} tracks` `[HAPPY MIX] After Standard fallback: ${tracks.length} tracks`
); );
} }
@@ -1715,13 +1772,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[HAPPY MIX] After genre fallback: ${tracks.length} tracks` `[HAPPY MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[HAPPY MIX] FAILED: Only ${tracks.length} tracks found` `[HAPPY MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -1774,7 +1831,7 @@ export class ProgrammaticPlaylistService {
include: { album: { select: { coverUrl: true } } }, include: { album: { select: { coverUrl: true } } },
take: 150, take: 150,
}); });
console.log( logger.debug(
`[MELANCHOLY MIX] Enhanced mode: Found ${enhancedTracks.length} tracks` `[MELANCHOLY MIX] Enhanced mode: Found ${enhancedTracks.length} tracks`
); );
@@ -1782,7 +1839,7 @@ export class ProgrammaticPlaylistService {
tracks = enhancedTracks; tracks = enhancedTracks;
} else { } else {
// Strategy 2: Standard mode fallback // Strategy 2: Standard mode fallback
console.log(`[MELANCHOLY MIX] Falling back to Standard mode`); logger.debug(`[MELANCHOLY MIX] Falling back to Standard mode`);
const audioTracks = await prisma.track.findMany({ const audioTracks = await prisma.track.findMany({
where: { where: {
analysisStatus: "completed", analysisStatus: "completed",
@@ -1792,7 +1849,7 @@ export class ProgrammaticPlaylistService {
include: { album: { select: { coverUrl: true } } }, include: { album: { select: { coverUrl: true } } },
take: 150, take: 150,
}); });
console.log( logger.debug(
`[MELANCHOLY MIX] Standard mode: Found ${audioTracks.length} low-valence tracks` `[MELANCHOLY MIX] Standard mode: Found ${audioTracks.length} low-valence tracks`
); );
@@ -1820,7 +1877,7 @@ export class ProgrammaticPlaylistService {
); );
return hasMinorKey || hasSadTags || hasLastfmSadTags; return hasMinorKey || hasSadTags || hasLastfmSadTags;
}); });
console.log( logger.debug(
`[MELANCHOLY MIX] After tag filter: ${tracks.length} tracks` `[MELANCHOLY MIX] After tag filter: ${tracks.length} tracks`
); );
} }
@@ -1844,14 +1901,14 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[MELANCHOLY MIX] After genre fallback: ${tracks.length} tracks` `[MELANCHOLY MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
// Require minimum 15 tracks for a meaningful playlist // Require minimum 15 tracks for a meaningful playlist
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[MELANCHOLY MIX] FAILED: Only ${tracks.length} tracks found` `[MELANCHOLY MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -1919,7 +1976,7 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = audioTracks; tracks = audioTracks;
console.log( logger.debug(
`[DANCE FLOOR MIX] Found ${tracks.length} tracks from audio analysis` `[DANCE FLOOR MIX] Found ${tracks.length} tracks from audio analysis`
); );
@@ -1943,13 +2000,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[DANCE FLOOR MIX] After genre fallback: ${tracks.length} tracks` `[DANCE FLOOR MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[DANCE FLOOR MIX] FAILED: Only ${tracks.length} tracks found` `[DANCE FLOOR MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -2002,7 +2059,7 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = audioTracks; tracks = audioTracks;
console.log( logger.debug(
`[ACOUSTIC MIX] Found ${tracks.length} tracks from audio analysis` `[ACOUSTIC MIX] Found ${tracks.length} tracks from audio analysis`
); );
@@ -2024,13 +2081,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[ACOUSTIC MIX] After genre fallback: ${tracks.length} tracks` `[ACOUSTIC MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[ACOUSTIC MIX] FAILED: Only ${tracks.length} tracks found` `[ACOUSTIC MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -2083,7 +2140,7 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = audioTracks; tracks = audioTracks;
console.log( logger.debug(
`[INSTRUMENTAL MIX] Found ${tracks.length} tracks from audio analysis` `[INSTRUMENTAL MIX] Found ${tracks.length} tracks from audio analysis`
); );
@@ -2106,13 +2163,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[INSTRUMENTAL MIX] After genre fallback: ${tracks.length} tracks` `[INSTRUMENTAL MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[INSTRUMENTAL MIX] FAILED: Only ${tracks.length} tracks found` `[INSTRUMENTAL MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -2226,7 +2283,7 @@ export class ProgrammaticPlaylistService {
take: 100, take: 100,
}); });
tracks = taggedTracks; tracks = taggedTracks;
console.log(`[ROAD TRIP MIX] Found ${tracks.length} tracks from tags`); logger.debug(`[ROAD TRIP MIX] Found ${tracks.length} tracks from tags`);
// Strategy 2: Audio analysis (medium-high energy, good tempo) // Strategy 2: Audio analysis (medium-high energy, good tempo)
if (tracks.length < 15) { if (tracks.length < 15) {
@@ -2244,7 +2301,7 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...audioTracks.filter((t) => !existingIds.has(t.id)), ...audioTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[ROAD TRIP MIX] After audio fallback: ${tracks.length} tracks` `[ROAD TRIP MIX] After audio fallback: ${tracks.length} tracks`
); );
} }
@@ -2267,13 +2324,13 @@ export class ProgrammaticPlaylistService {
...tracks, ...tracks,
...albumGenreTracks.filter((t) => !existingIds.has(t.id)), ...albumGenreTracks.filter((t) => !existingIds.has(t.id)),
]; ];
console.log( logger.debug(
`[ROAD TRIP MIX] After genre fallback: ${tracks.length} tracks` `[ROAD TRIP MIX] After genre fallback: ${tracks.length} tracks`
); );
} }
if (tracks.length < 15) { if (tracks.length < 15) {
console.log( logger.debug(
`[ROAD TRIP MIX] FAILED: Only ${tracks.length} tracks found` `[ROAD TRIP MIX] FAILED: Only ${tracks.length} tracks found`
); );
return null; return null;
@@ -3582,7 +3639,7 @@ export class ProgrammaticPlaylistService {
useEnhancedMode = true; useEnhancedMode = true;
} else { } else {
// Not enough enhanced tracks - convert ML mood params to basic audio feature equivalents // Not enough enhanced tracks - convert ML mood params to basic audio feature equivalents
console.log( logger.debug(
`[MoodMixer] Only ${enhancedCount} enhanced tracks, falling back to basic features` `[MoodMixer] Only ${enhancedCount} enhanced tracks, falling back to basic features`
); );

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