Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d71da230 | |||
| bddea9ef36 | |||
| 0ac805b6fc | |||
| ce597a318e | |||
| ffb8bda9d1 | |||
| d78eaed15b | |||
| cc8d0f6969 | |||
| 8fe151a0d1 | |||
| 26d73f63a3 | |||
| e3fe3c6f48 | |||
| 8910ce1407 | |||
| 8dc0d3ade3 | |||
| 567f38e1ea |
+46
-3
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
name: Build and Publish Docker Image
|
name: Release ${{ github.ref_name }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Executable
+200
@@ -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 "=============================================================================="
|
||||||
Executable
+107
@@ -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 "=============================================================================="
|
||||||
@@ -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..."
|
||||||
|
|||||||
Generated
+91
-62
@@ -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",
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
+11
@@ -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");
|
||||||
+128
@@ -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;
|
||||||
+2
@@ -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
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+452
-248
File diff suppressed because it is too large
Load Diff
+232
-65
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+658
-404
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"
|
||||||
}`
|
}`
|
||||||
|
|||||||
@@ -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
@@ -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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+952
-311
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user