Initial release v1.0.0

This commit is contained in:
Kevin O'Neill
2025-12-25 18:58:06 -06:00
commit 021aec7a63
439 changed files with 116588 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# Dependencies
node_modules
npm-debug.log
yarn-error.log
# Build output
dist
build
*.tsbuildinfo
# Environment files
.env
.env.local
.env.*.local
# Testing
coverage
*.test.ts
**/__tests__
# Development
.vscode
.idea
*.swp
*.swo
*~
# Cache and logs
cache
logs
*.log
# Git
.git
.gitignore
# Documentation
*.md
docs
# Misc
.DS_Store
Thumbs.db
+21
View File
@@ -0,0 +1,21 @@
node_modules/
dist/
.env
.env.*
.DS_Store
logs/
*.log
# Runtime caches (safe to delete; regenerated)
cache/
# VPN configs for local testing (do not commit)
mullvad/
# Stray media artifacts (should never be committed)
*.mp3
*.flac
*.wav
*.m4a
*.ogg
*.opus
+65
View File
@@ -0,0 +1,65 @@
# Stage 1: Dependencies (all deps for tsx runtime)
FROM node:20-slim AS deps
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY prisma ./prisma/
# Install ALL dependencies (tsx needs dev dependencies)
RUN npm ci && \
npm cache clean --force
# Generate Prisma Client
RUN npx prisma generate
# Stage 2: Production runtime (Hardened)
FROM node:20-slim
WORKDIR /app
# Install runtime dependencies first
# ffmpeg is required for audio transcoding
# openssl is required for Prisma
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
tini \
openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all node_modules (including tsx)
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package*.json ./
COPY --from=deps /app/prisma ./prisma
# Copy source code (will run with tsx, not compiled)
COPY src ./src
# Copy healthcheck script and shell entrypoint
COPY healthcheck.js ./
COPY docker-entrypoint.sh /usr/local/bin/
# Create directories, fix line endings, set permissions, then remove dangerous tools
# NOTE: We keep /bin/sh because npm/npx require it to spawn processes
RUN mkdir -p /app/cache/covers /app/cache/transcodes /app/logs && \
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && \
chmod +x /usr/local/bin/docker-entrypoint.sh && \
chown -R node:node /app && \
# Remove download/network utilities (prevents downloading malware)
rm -f /usr/bin/wget /usr/bin/curl /bin/wget /bin/curl 2>/dev/null || true && \
rm -f /usr/bin/nc /bin/nc /usr/bin/ncat /usr/bin/netcat 2>/dev/null || true && \
rm -f /usr/bin/ftp /usr/bin/tftp /usr/bin/telnet 2>/dev/null || true
# Use non-root user
USER node
EXPOSE 3006
# Health check using Node.js (no wget needed)
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["node", "healthcheck.js"]
# Use tini for proper signal handling
ENTRYPOINT ["/usr/bin/tini", "--", "docker-entrypoint.sh"]
CMD ["npx", "tsx", "src/index.ts"]
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
set -e
# Security check: Refuse to run as root
if [ "$(id -u)" = "0" ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ FATAL: CANNOT START AS ROOT ║"
echo "║ ║"
echo "║ Running as root is a security risk. This container must ║"
echo "║ run as a non-privileged user. ║"
echo "║ ║"
echo "║ Do NOT use: ║"
echo "║ - docker run --user root ║"
echo "║ - user: root in docker-compose.yml ║"
echo "║ ║"
echo "║ The container is configured to run as 'node' user. ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
echo "[START] Starting Lidify Backend..."
# Docker Compose health checks ensure database and Redis are ready
# Add a small delay to be extra safe
echo "[WAIT] Waiting for services to be ready..."
sleep 3
echo "Services are ready"
# Run database migrations
echo "[DB] Running database migrations..."
npx prisma migrate deploy
# Generate Prisma client (in case of schema changes)
echo "[DB] Generating Prisma client..."
npx prisma generate
# Generate session secret if not provided
if [ -z "$SESSION_SECRET" ] || [ "$SESSION_SECRET" = "changeme-generate-secure-key" ]; then
echo "[WARN] SESSION_SECRET not set or using default. Generating random key..."
export SESSION_SECRET=$(node -e "console.log(require('crypto').randomBytes(32).toString('base64'))")
echo "Generated SESSION_SECRET (will not persist across restarts - set it in .env for production)"
fi
# Ensure encryption key is stable between restarts
if [ -z "$SETTINGS_ENCRYPTION_KEY" ]; then
echo "[WARN] SETTINGS_ENCRYPTION_KEY not set."
echo " Falling back to the default development key so encrypted data remains readable."
echo " Set SETTINGS_ENCRYPTION_KEY in your environment to a 32-character value for production."
export SETTINGS_ENCRYPTION_KEY="default-encryption-key-change-me"
fi
echo "[START] Lidify Backend starting on port ${PORT:-3006}..."
echo "[CONFIG] Music path: ${MUSIC_PATH:-/music}"
echo "[CONFIG] Environment: ${NODE_ENV:-production}"
# Execute the main command
exec "$@"
+24
View File
@@ -0,0 +1,24 @@
// Minimal health check script - no external dependencies
const http = require('http');
const options = {
hostname: 'localhost',
port: 3006,
path: '/health',
method: 'GET',
timeout: 5000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode >= 200 && res.statusCode < 400 ? 0 : 1);
});
req.on('error', () => process.exit(1));
req.on('timeout', () => {
req.destroy();
process.exit(1);
});
req.end();
+5578
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
{
"name": "lidify-backend",
"version": "1.2.0",
"description": "Lidify backend API server",
"license": "GPL-3.0",
"repository": {
"type": "git",
"url": "https://github.com/Chevron7Locked/lidify.git"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate deploy",
"db:studio": "prisma studio",
"seed:user": "tsx seeds/createUser.ts",
"test:smoke": "tsx scripts/smoke.ts",
"sync": "tsx src/workers/sync.ts"
},
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@prisma/client": "^5.22.0",
"@types/bull": "^3.15.9",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/node-cron": "^3.0.11",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"bull": "^4.16.5",
"connect-redis": "^7.1.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^8.2.1",
"express-session": "^1.17.3",
"ffmpeg-static": "^5.2.0",
"fluent-ffmpeg": "^2.1.3",
"fuzzball": "^2.2.3",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"music-metadata": "^11.10.0",
"node-cron": "^4.2.1",
"p-queue": "^9.0.0",
"podcast-index-api": "^1.1.10",
"qrcode": "^1.5.4",
"redis": "^4.6.10",
"rss-parser": "^3.13.0",
"sharp": "^0.34.5",
"slsk-client": "^1.1.0",
"speakeasy": "^2.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.4",
"prisma": "^5.22.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,12 @@
-- Add updatedAt column to Track if it doesn't exist
-- This handles databases that were created before this column was added to the schema
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'Track' AND column_name = 'updatedAt'
) THEN
ALTER TABLE "Track" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
END IF;
END $$;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+926
View File
@@ -0,0 +1,926 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
username String @unique
passwordHash String
role String @default("user") // user, admin
onboardingComplete Boolean @default(false) // Tracks if user completed setup
enrichmentSettings Json? // JSON settings for metadata enrichment
twoFactorEnabled Boolean @default(false) // 2FA enabled flag
twoFactorSecret String? // TOTP secret (encrypted)
twoFactorRecoveryCodes String? // Recovery codes (encrypted, comma-separated hashed codes)
moodMixParams Json? // Saved mood mix parameters for "Your Mood Mix"
createdAt DateTime @default(now())
plays Play[]
playlists Playlist[]
listeningState ListeningState[]
downloadJobs DownloadJob[]
spotifyImportJobs SpotifyImportJob[]
settings UserSettings?
playbackState PlaybackState?
likedTracks LikedTrack[]
dislikedEntities DislikedEntity[]
cachedTracks CachedTrack[]
audiobookProgress AudiobookProgress[]
podcastSubscriptions PodcastSubscription[]
podcastProgress PodcastProgress[]
podcastDownloads PodcastDownload[]
discoveryAlbums DiscoveryAlbum[]
discoverExclusions DiscoverExclusion[]
discoverConfig UserDiscoverConfig?
unavailableAlbums UnavailableAlbum[]
apiKeys ApiKey[]
deviceLinkCodes DeviceLinkCode[]
hiddenPlaylists HiddenPlaylist[]
notifications Notification[]
}
model UserSettings {
userId String @id
playbackQuality String @default("original") // original, high, medium, low
wifiOnly Boolean @default(false)
offlineEnabled Boolean @default(false)
maxCacheSizeMb Int @default(10240) // 10GB default
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model PlaybackState {
userId String @id
playbackType String // track, audiobook, podcast
trackId String? // For music tracks
audiobookId String? // For audiobooks (audiobookshelfId)
podcastId String? // For podcasts (format: podcastId:episodeId)
queue Json? // JSON array of track IDs
currentIndex Int @default(0)
isShuffle Boolean @default(false)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model SystemSettings {
id String @id @default("default")
// === Download Services ===
// Lidarr
lidarrEnabled Boolean @default(true)
lidarrUrl String? @default("http://localhost:8686")
lidarrApiKey String? // Encrypted
// === AI Services ===
// OpenAI (for future AI features)
openaiEnabled Boolean @default(false)
openaiApiKey String? // Encrypted
openaiModel String? @default("gpt-4")
openaiBaseUrl String? // For custom endpoints
// Fanart.tv (for high-quality images - optional)
fanartEnabled Boolean @default(false)
fanartApiKey String? // Encrypted
// === Media Services ===
// Audiobookshelf
audiobookshelfEnabled Boolean @default(false)
audiobookshelfUrl String? @default("http://localhost:13378")
audiobookshelfApiKey String? // Encrypted
// Soulseek (direct connection via slsk-client)
soulseekUsername String? // Soulseek network username
soulseekPassword String? // Soulseek network password - Encrypted
// Spotify (for playlist import via URL)
spotifyClientId String?
spotifyClientSecret String? // Encrypted
// === Storage Paths ===
musicPath String? @default("/music")
downloadPath String? @default("/downloads")
// === Feature Flags ===
autoSync Boolean @default(true)
autoEnrichMetadata Boolean @default(true)
// === Advanced Settings ===
maxConcurrentDownloads Int @default(3)
downloadRetryAttempts Int @default(3)
transcodeCacheMaxGb Int @default(10) // Transcode cache size limit in GB
// === Download Preferences ===
// Primary download source: "soulseek" (per-track) or "lidarr" (full albums)
downloadSource String @default("soulseek")
// When soulseek is primary and fails: "none" (skip) or "lidarr" (download full album)
soulseekFallback String @default("none")
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
model Artist {
id String @id @default(cuid())
mbid String @unique
name String
normalizedName String @default("") // Lowercase version for case-insensitive matching
summary String? @db.Text
heroUrl String?
genres Json? // Array of genre strings from Last.fm/MusicBrainz
lastSynced DateTime @default(now())
lastEnriched DateTime?
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
searchVector Unsupported("tsvector")?
albums Album[]
similarFrom SimilarArtist[] @relation("FromArtist")
similarTo SimilarArtist[] @relation("ToArtist")
ownedAlbums OwnedAlbum[]
@@index([name])
@@index([normalizedName])
@@index([searchVector], type: Gin)
}
model Album {
id String @id @default(cuid())
rgMbid String @unique // release group MBID
artistId String
title String
year Int?
coverUrl String?
primaryType String // Album, EP, Single, Live, Compilation
label String? // Record label (from MusicBrainz)
genres Json? // Array of genre strings from Last.fm
lastSynced DateTime @default(now())
location AlbumLocation @default(LIBRARY) // LIBRARY or DISCOVER
searchVector Unsupported("tsvector")?
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
tracks Track[]
@@index([artistId])
@@index([location])
@@index([title])
@@index([searchVector], type: Gin)
}
model Track {
id String @id @default(cuid())
albumId String
title String
trackNo Int
duration Int // seconds
mime String?
searchVector Unsupported("tsvector")?
// Native file system fields (required for self-contained system)
filePath String @unique // Relative path: "Artist/Album/track.flac"
fileModified DateTime // mtime for change detection
fileSize Int // File size in bytes
// === Audio Analysis (Essentia) ===
// Rhythm
bpm Float? // Beats per minute (e.g., 120.5)
beatsCount Int? // Total beats in track
// Tonality
key String? // Musical key (e.g., "C", "F#", "Bb")
keyScale String? // "major" or "minor"
keyStrength Float? // Confidence 0-1
// Energy & Dynamics
energy Float? // Overall energy 0-1
loudness Float? // Average loudness in dB
dynamicRange Float? // Dynamic range in dB
// Mood & Character (basic/estimated)
danceability Float? // 0-1 how suitable for dancing
valence Float? // 0 (sad) to 1 (happy) - ML in Enhanced mode
arousal Float? // 0 (calm) to 1 (energetic) - ML in Enhanced mode
// Instrumentation
instrumentalness Float? // 0-1 (1 = no vocals) - ML in Enhanced mode
acousticness Float? // 0-1 (1 = acoustic)
speechiness Float? // 0-1 (1 = spoken word)
// === Enhanced Mode: ML Mood Predictions ===
moodHappy Float? // ML prediction 0-1 (probability of happy)
moodSad Float? // ML prediction 0-1 (probability of sad)
moodRelaxed Float? // ML prediction 0-1 (probability of relaxed)
moodAggressive Float? // ML prediction 0-1 (probability of aggressive)
moodParty Float? // ML prediction 0-1 (probability of party/upbeat)
moodAcoustic Float? // ML prediction 0-1 (probability of acoustic)
moodElectronic Float? // ML prediction 0-1 (probability of electronic)
danceabilityMl Float? // ML-based danceability (more accurate than basic)
// Mood Tags (derived from ML or heuristics)
moodTags String[] // ["aggressive", "happy", "sad", "relaxed"]
// Genre (ML classification from Essentia, backup to Last.fm)
essentiaGenres String[] // ["rock", "electronic", "jazz"]
// Last.fm Tags (user-generated mood/vibe tags)
lastfmTags String[] // ["chill", "workout", "sad", "90s"]
// Analysis Metadata
analysisStatus String @default("pending") // pending, processing, completed, failed
analysisVersion String? // Essentia version used
analysisMode String? // 'standard' or 'enhanced'
analyzedAt DateTime?
analysisError String? // Error message if failed
analysisRetryCount Int @default(0) // Number of retry attempts
updatedAt DateTime @updatedAt
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
plays Play[]
playlistItems PlaylistItem[]
trackGenres TrackGenre[]
likedBy LikedTrack[]
cachedBy CachedTrack[]
transcodedFiles TranscodedFile[]
@@index([albumId])
@@index([fileModified])
@@index([title])
@@index([searchVector], type: Gin)
@@index([analysisStatus])
@@index([bpm])
@@index([energy])
@@index([valence])
@@index([danceability])
}
// Transcoded file cache for audio streaming
model TranscodedFile {
id String @id @default(cuid())
trackId String
quality String // original, high, medium, low
cachePath String @unique // Relative path in transcode cache
cacheSize Int // File size in bytes
sourceModified DateTime // For invalidation
lastAccessed DateTime @default(now()) // For LRU eviction
createdAt DateTime @default(now())
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
@@unique([trackId, quality])
@@index([trackId, quality])
@@index([lastAccessed])
}
model Play {
id String @id @default(cuid())
userId String
trackId String
playedAt DateTime @default(now())
source ListenSource @default(LIBRARY) // LIBRARY, DISCOVERY, or DISCOVERY_KEPT
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
@@index([userId, playedAt])
@@index([trackId])
@@index([source])
}
model Playlist {
id String @id @default(cuid())
userId String
mixId String?
name String
isPublic Boolean @default(false)
createdAt DateTime @default(now())
// Spotify import metadata
spotifyPlaylistId String? // Original Spotify playlist ID
spotifyPlaylistUrl String? // Original Spotify URL for re-import
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items PlaylistItem[]
pendingTracks PlaylistPendingTrack[]
hiddenByUsers HiddenPlaylist[]
@@unique([userId, mixId])
@@index([userId])
@@index([spotifyPlaylistId])
}
// Track which users have hidden which shared playlists
model HiddenPlaylist {
id String @id @default(cuid())
userId String
playlistId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
@@unique([userId, playlistId])
@@index([userId])
}
model PlaylistItem {
id String @id @default(cuid())
playlistId String
trackId String
sort Int
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
@@unique([playlistId, trackId])
@@index([playlistId, sort])
}
// Tracks from Spotify imports that haven't been matched to local library yet
// These are automatically added to the playlist when the matching track is downloaded
model PlaylistPendingTrack {
id String @id @default(cuid())
playlistId String
spotifyArtist String // Original artist name from Spotify
spotifyTitle String // Original track title from Spotify
spotifyAlbum String // Original album name from Spotify
albumMbid String? // MusicBrainz album ID if resolved
artistMbid String? // MusicBrainz artist ID if resolved
deezerPreviewUrl String? // Deezer 30s preview URL for playback while pending
sort Int // Position in original playlist
createdAt DateTime @default(now())
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
@@unique([playlistId, spotifyArtist, spotifyTitle]) // Prevent duplicates
@@index([playlistId])
@@index([albumMbid])
@@index([artistMbid])
}
// Spotify Import Jobs - tracks import progress and state
model SpotifyImportJob {
id String @id
userId String
spotifyPlaylistId String
playlistName String
status String // pending, downloading, scanning, creating_playlist, completed, failed, cancelled
progress Int @default(0) // 0-100
albumsTotal Int
albumsCompleted Int @default(0)
tracksTotal Int
tracksMatched Int @default(0)
tracksDownloadable Int @default(0)
createdPlaylistId String?
error String?
pendingTracks Json // Array of pending track objects
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@index([createdAt])
}
model Genre {
id String @id @default(cuid())
name String @unique
trackGenres TrackGenre[]
}
model TrackGenre {
trackId String
genreId String
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
@@id([trackId, genreId])
@@index([genreId])
}
model SimilarArtist {
fromArtistId String
toArtistId String
weight Float @default(1.0)
fromArtist Artist @relation("FromArtist", fields: [fromArtistId], references: [id], onDelete: Cascade)
toArtist Artist @relation("ToArtist", fields: [toArtistId], references: [id], onDelete: Cascade)
@@id([fromArtistId, toArtistId])
@@index([fromArtistId])
}
model OwnedAlbum {
artistId String
rgMbid String
source String // lidarr, manual, native_scan
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
@@id([artistId, rgMbid])
@@index([artistId])
}
model DownloadJob {
id String @id @default(cuid())
correlationId String? @unique // UUID for reliable webhook matching
userId String
subject String // artist name or album title
type String // artist, album
targetMbid String // artist MBID or release group MBID
status String // pending, processing, completed, failed, exhausted
error String? // error message if failed
lidarrRef String? // Lidarr's downloadId from webhook
lidarrAlbumId Int? // Lidarr's internal album ID for retry/cleanup
metadata Json? // additional metadata (downloadType, rootFolderPath, etc.)
attempts Int @default(0) // Number of download attempts
startedAt DateTime? // When download was initiated (for timeout tracking)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
discoveryBatchId String? // Links to Discovery Weekly batch if part of discovery
// Release iteration tracking (for exhaustive retry)
triedReleases String[] @default([]) // GUIDs of releases we've tried
releaseIndex Int @default(0) // Current position in release list
artistMbid String? // Artist MBID for same-artist fallback
cleared Boolean @default(false) // User dismissed from history
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
discoveryBatch DiscoveryBatch? @relation(fields: [discoveryBatchId], references: [id], onDelete: SetNull)
@@index([userId, status])
@@index([status])
@@index([discoveryBatchId])
@@index([correlationId])
@@index([startedAt])
@@index([lidarrRef])
@@index([artistMbid])
}
model ListeningState {
id String @id @default(cuid())
userId String
kind String // music, book
entityId String // artist/album/book ID
trackId String? // current track/chapter
positionMs Int @default(0)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, kind, entityId])
@@index([userId])
}
model DiscoveryAlbum {
id String @id @default(cuid())
userId String
rgMbid String
artistName String
artistMbid String?
albumTitle String
lidarrAlbumId Int?
downloadedAt DateTime?
folderPath String @default("")
weekStartDate DateTime // When it was added to discovery
weekEndDate DateTime @default(now()) // When it expires
status DiscoverStatus @default(ACTIVE)
likedAt DateTime? // When user liked it
similarity Float? // Similarity score from Last.fm (0-1)
tier String? // high, medium, low, wild
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tracks DiscoveryTrack[]
@@unique([userId, weekStartDate, rgMbid])
@@index([userId, weekStartDate])
@@index([downloadedAt])
@@index([status])
}
model DiscoveryTrack {
id String @id @default(cuid())
discoveryAlbumId String
trackId String?
fileName String
filePath String
inPlaylistCount Int @default(0)
userKept Boolean @default(false)
lastPlayedAt DateTime?
discoveryAlbum DiscoveryAlbum @relation(fields: [discoveryAlbumId], references: [id], onDelete: Cascade)
@@index([discoveryAlbumId])
@@index([userKept])
@@index([lastPlayedAt])
}
model LikedTrack {
userId String
trackId String
likedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
@@id([userId, trackId])
@@index([userId])
@@index([likedAt])
}
model DislikedEntity {
id String @id @default(cuid())
userId String
entityType String // track, album, artist
entityId String // trackId, albumId, artistId
dislikedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, entityType, entityId])
@@index([userId, entityType])
}
model CachedTrack {
id String @id @default(cuid())
userId String
trackId String
localPath String
quality String // original, high, medium, low
fileSizeMb Float
cachedAt DateTime @default(now())
lastAccessedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
@@unique([userId, trackId, quality])
@@index([userId])
@@index([lastAccessedAt])
}
model AudiobookProgress {
id String @id @default(cuid())
userId String
audiobookshelfId String // The audiobook ID from Audiobookshelf
title String // Cached for display
author String? // Cached for display
coverUrl String? // Cached for display
currentTime Float @default(0) // Current playback position in seconds
duration Float @default(0) // Total duration in seconds
isFinished Boolean @default(false)
lastPlayedAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Note: No foreign key to Audiobook - progress can exist before audiobook is cached
@@unique([userId, audiobookshelfId])
@@index([userId, lastPlayedAt])
}
// ============================================
// Cached Audiobooks (from Audiobookshelf)
// ============================================
model Audiobook {
id String @id // Audiobookshelf item ID
title String
author String?
narrator String?
description String? @db.Text
publishedYear Int?
publisher String?
// Series info
series String?
seriesSequence String?
// Media info
duration Float? // seconds
numTracks Int?
numChapters Int?
size BigInt? // bytes
// Metadata
isbn String?
asin String?
language String?
genres String[] // array of genres
tags String[] // array of tags
// Files
localCoverPath String? // local cached cover image path
coverUrl String? // original Audiobookshelf URL
audioUrl String // Audiobookshelf streaming URL
libraryId String? // Audiobookshelf library ID
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastSyncedAt DateTime @default(now())
@@index([title])
@@index([author])
@@index([series])
@@index([lastSyncedAt])
}
model PodcastRecommendation {
id String @id @default(cuid())
podcastId String // The source podcast ID (from Audiobookshelf)
recommendedId String // Unique ID for the recommended podcast
title String
author String?
description String? @db.Text
coverUrl String?
episodeCount Int @default(0)
feedUrl String?
itunesId String?
score Float @default(0) // Relevance score
cachedAt DateTime @default(now())
expiresAt DateTime // When this recommendation expires (30 days from cache)
@@index([podcastId, expiresAt])
@@index([expiresAt]) // For cleanup of expired recommendations
@@map("podcast_recommendations")
}
// ============================================
// NEW: Independent Podcast System (RSS-based)
// ============================================
model Podcast {
id String @id @default(cuid())
feedUrl String @unique
title String
author String?
description String? @db.Text
imageUrl String? // Original feed image URL
localCoverPath String? // Local cached cover image path
itunesId String? @unique
language String?
explicit Boolean @default(false)
episodeCount Int @default(0)
lastRefreshed DateTime @default(now())
refreshInterval Int @default(3600) // seconds (1 hour default)
autoRefresh Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
episodes PodcastEpisode[]
subscriptions PodcastSubscription[]
@@index([itunesId])
@@index([lastRefreshed])
}
model PodcastEpisode {
id String @id @default(cuid())
podcastId String
guid String // RSS GUID (unique per feed)
title String
description String? @db.Text
audioUrl String // Direct MP3/audio URL from RSS
duration Int @default(0) // seconds
publishedAt DateTime
episodeNumber Int?
season Int?
imageUrl String? // Episode-specific image URL
localCoverPath String? // Local cached episode cover
fileSize Int? // bytes
mimeType String? @default("audio/mpeg")
createdAt DateTime @default(now())
podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
progress PodcastProgress[]
downloads PodcastDownload[]
@@unique([podcastId, guid])
@@index([podcastId, publishedAt])
}
// User podcast subscriptions
model PodcastSubscription {
userId String
podcastId String
subscribedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
@@id([userId, podcastId])
@@index([userId])
@@index([podcastId])
}
// Listening progress for podcast episodes
model PodcastProgress {
id String @id @default(cuid())
userId String
episodeId String
currentTime Float @default(0) // seconds
duration Float @default(0) // seconds
isFinished Boolean @default(false)
lastPlayedAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
@@unique([userId, episodeId])
@@index([userId, lastPlayedAt])
}
// Downloaded episodes for offline playback
model PodcastDownload {
id String @id @default(cuid())
userId String
episodeId String
localPath String // Where the file is stored locally
fileSizeMb Float
downloadedAt DateTime @default(now())
lastAccessedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
@@unique([userId, episodeId])
@@index([userId])
@@index([lastAccessedAt]) // For cache cleanup
}
// ============================================
// Discover Weekly System
// ============================================
// Album exclusion tracking - prevents suggesting the same album for 6 months
model DiscoverExclusion {
id String @id @default(cuid())
userId String
albumMbid String // MusicBrainz release group ID
artistName String? // For display purposes
albumTitle String? // For display purposes
lastSuggestedAt DateTime @default(now())
expiresAt DateTime // 6 months from lastSuggestedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, albumMbid])
@@index([userId, expiresAt])
}
// User configuration for Discover Weekly
model UserDiscoverConfig {
id String @id @default(cuid())
userId String @unique
playlistSize Int @default(40) // 5-50, increments of 5
maxRetryAttempts Int @default(3) // 1-10, how many times to retry finding replacements
exclusionMonths Int @default(6) // 0-12, months to exclude albums after download (0 = no exclusion)
downloadRatio Float @default(1.3) // 1.0-2.0, multiplier for albums to request vs target songs
enabled Boolean @default(true)
lastGeneratedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// Unavailable albums (recommended but not found in Lidarr)
model UnavailableAlbum {
id String @id @default(cuid())
userId String
artistName String
albumTitle String
albumMbid String
artistMbid String?
similarity Float // Similarity score from Last.fm (0-1)
tier String // high, medium, low, wild
weekStartDate DateTime // When it was recommended
previewUrl String? // 30-second preview from Deezer
deezerTrackId String? // Deezer track ID for preview
deezerAlbumId String? // Deezer album ID for preview
attemptNumber Int @default(0) // 0 = original, 1 = first replacement, 2 = second replacement, etc.
originalAlbumId String? // References the original album's ID if this is a replacement
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, weekStartDate, albumMbid])
@@index([userId, weekStartDate])
@@index([userId, weekStartDate, attemptNumber])
@@index([originalAlbumId])
}
// Batch tracking for Discovery Weekly generation
model DiscoveryBatch {
id String @id @default(cuid())
userId String
weekStart DateTime // Week this batch is for
targetSongCount Int // Target number of songs to find
status String @default("downloading") // downloading, scanning, completed, failed
totalAlbums Int @default(0) // Total albums queued for download
completedAlbums Int @default(0) // Albums successfully downloaded
failedAlbums Int @default(0) // Albums that failed to download
finalSongCount Int @default(0) // Final number of songs in playlist
logs Json? // Structured logs for debugging [{timestamp, level, message}]
errorMessage String? // Summary error message if failed
createdAt DateTime @default(now())
completedAt DateTime?
jobs DownloadJob[]
@@index([userId, weekStart])
@@index([status])
@@index([createdAt])
}
// ============================================
// API Keys for Mobile/External Authentication
// ============================================
model ApiKey {
id String @id @default(cuid())
userId String
key String @unique // 64-character hex string
name String // Device name: "iPhone 14", "Android Tablet"
lastUsed DateTime @default(now())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([key])
@@index([userId])
@@index([lastUsed])
}
// Temporary device link codes for QR login
model DeviceLinkCode {
id String @id @default(cuid())
code String @unique // 6-digit alphanumeric code
userId String // User who generated this code
expiresAt DateTime // 5 minutes from creation
usedAt DateTime? // When the code was used
deviceName String? // Name of device that used the code
apiKeyId String? // The API key created when code was used
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([code, expiresAt])
@@index([userId])
}
// ============================================
// Enums
// ============================================
enum DiscoverStatus {
ACTIVE // Currently in discover folder
LIKED // User liked, will be moved to permanent
MOVED // Already moved to permanent library
DELETED // Week ended, was not liked
}
enum ListenSource {
LIBRARY // From permanent /music folder
DISCOVERY // From /music/Discover, not yet liked
DISCOVERY_KEPT // Was discovery, user liked it
}
enum AlbumLocation {
LIBRARY // In /music
DISCOVER // In /music/Discover
}
// ============================================
// Notifications System
// ============================================
model Notification {
id String @id @default(cuid())
userId String
type String // system, download_complete, playlist_ready, error, import_complete
title String
message String?
metadata Json? // { playlistId, albumId, artistId, etc. }
read Boolean @default(false)
cleared Boolean @default(false)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, cleared])
@@index([userId, read])
@@index([createdAt])
}
+49
View File
@@ -0,0 +1,49 @@
import bcrypt from "bcrypt";
import { PrismaClient } from "@prisma/client";
import * as readline from "readline";
const prisma = new PrismaClient();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function prompt(question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => resolve(answer));
});
}
async function main() {
const username = await prompt("Username: ");
const password = await prompt("Password: ");
const role = await prompt("Role (user/admin) [user]: ");
if (!username || !password) {
console.error("Username and password required");
process.exit(1);
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
role: role || "user",
},
});
console.log(`\nCreated user: ${user.username} (${user.role})`);
rl.close();
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+110
View File
@@ -0,0 +1,110 @@
import dotenv from "dotenv";
import { z } from "zod";
import { validateMusicConfig, MusicConfig } from "./utils/configValidator";
dotenv.config();
// Validate critical environment variables on startup
const envSchema = z.object({
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
REDIS_URL: z.string().min(1, "REDIS_URL is required"),
SESSION_SECRET: z
.string()
.min(32, "SESSION_SECRET must be at least 32 characters"),
PORT: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
MUSIC_PATH: z.string().min(1, "MUSIC_PATH is required"),
});
try {
envSchema.parse(process.env);
console.log("Environment variables validated");
} catch (error) {
if (error instanceof z.ZodError) {
console.error(" Environment validation failed:");
error.errors.forEach((err) => {
console.error(` - ${err.path.join(".")}: ${err.message}`);
});
console.error(
"\n Please check your .env file and ensure all required variables are set."
);
process.exit(1);
}
}
// Music config - will be initialized async
let musicConfig: MusicConfig = {
musicPath: process.env.MUSIC_PATH || "/music",
transcodeCachePath:
process.env.TRANSCODE_CACHE_PATH || "./cache/transcodes",
transcodeCacheMaxGb: parseInt(
process.env.TRANSCODE_CACHE_MAX_GB || "10",
10
),
};
// Initialize music configuration asynchronously
export async function initializeMusicConfig() {
try {
musicConfig = await validateMusicConfig();
console.log("Music configuration initialized");
} catch (err: any) {
console.error(" Configuration validation failed:", err.message);
console.warn(" Using default/environment configuration");
// Don't exit process - allow app to start for other features
// Music features will fail gracefully if config is invalid
}
}
export const config = {
port: parseInt(process.env.PORT || "3006", 10),
nodeEnv: process.env.NODE_ENV || "development",
// DATABASE_URL and REDIS_URL are validated by envSchema above, so they're guaranteed to exist
databaseUrl: process.env.DATABASE_URL!,
redisUrl: process.env.REDIS_URL!,
sessionSecret: process.env.SESSION_SECRET!,
// Music library configuration (self-contained native music system)
// Access via config.music - will be updated after initialization
get music() {
return musicConfig;
},
// Lidarr - now reads from database via lidarrService.ensureInitialized()
lidarr:
process.env.LIDARR_ENABLED === "true"
? {
url: process.env.LIDARR_URL!,
apiKey: process.env.LIDARR_API_KEY!,
enabled: true,
}
: undefined,
// Last.fm - ships with default app key, users can override in settings
lastfm: {
// Default application API key (free tier, for public use)
// Users can override this in System Settings with their own key
apiKey: process.env.LASTFM_API_KEY || "c1797de6bf0b7e401b623118120cd9e1",
},
// OpenAI - reads from database
openai: {
apiKey: process.env.OPENAI_API_KEY || "", // Fallback to DB
},
// Deezer - reads from database
deezer: {
apiKey: process.env.DEEZER_API_KEY || "", // Fallback to DB
},
audiobookshelf: process.env.AUDIOBOOKSHELF_URL
? {
url: process.env.AUDIOBOOKSHELF_URL,
token: process.env.AUDIOBOOKSHELF_TOKEN!,
}
: undefined,
allowedOrigins:
process.env.ALLOWED_ORIGINS?.split(",").map((o) => o.trim()) ||
(process.env.NODE_ENV === "development" ? true : []),
};
+103
View File
@@ -0,0 +1,103 @@
import swaggerJsdoc from "swagger-jsdoc";
import { config } from "../config";
const options: swaggerJsdoc.Options = {
definition: {
openapi: "3.0.0",
info: {
title: "Lidify API",
version: "1.0.0",
description:
"Self-hosted music streaming server with Discover Weekly and full-text search",
contact: {
name: "Lidify",
url: "https://github.com/Chevron7Locked/lidify",
},
},
servers: [
{
url: `http://localhost:${config.port}`,
description: "Development server",
},
],
components: {
securitySchemes: {
sessionAuth: {
type: "apiKey",
in: "cookie",
name: "connect.sid",
description: "Session cookie authentication (web UI)",
},
apiKeyAuth: {
type: "apiKey",
in: "header",
name: "X-API-Key",
description: "API key authentication (mobile apps)",
},
},
schemas: {
User: {
type: "object",
properties: {
id: { type: "string" },
username: { type: "string" },
role: { type: "string", enum: ["user", "admin"] },
createdAt: { type: "string", format: "date-time" },
},
},
Artist: {
type: "object",
properties: {
id: { type: "string" },
mbid: { type: "string" },
name: { type: "string" },
heroUrl: { type: "string", nullable: true },
summary: { type: "string", nullable: true },
},
},
Album: {
type: "object",
properties: {
id: { type: "string" },
rgMbid: { type: "string" },
artistId: { type: "string" },
title: { type: "string" },
year: { type: "integer", nullable: true },
coverUrl: { type: "string", nullable: true },
primaryType: { type: "string" },
},
},
Track: {
type: "object",
properties: {
id: { type: "string" },
albumId: { type: "string" },
title: { type: "string" },
trackNo: { type: "integer" },
duration: { type: "integer" },
filePath: { type: "string" },
},
},
ApiKey: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
lastUsed: { type: "string", format: "date-time" },
createdAt: { type: "string", format: "date-time" },
},
},
Error: {
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
security: [{ sessionAuth: [] }, { apiKeyAuth: [] }],
},
apis: ["./src/routes/*.ts", "./src/config/swaggerSchemas.ts"],
};
export const swaggerSpec = swaggerJsdoc(options);
+300
View File
@@ -0,0 +1,300 @@
import express from "express";
import session from "express-session";
import RedisStore from "connect-redis";
import cors from "cors";
import helmet from "helmet";
import { config } from "./config";
import { redisClient } from "./utils/redis";
import { prisma } from "./utils/db";
import authRoutes from "./routes/auth";
import onboardingRoutes from "./routes/onboarding";
import libraryRoutes from "./routes/library";
import playsRoutes from "./routes/plays";
import settingsRoutes from "./routes/settings";
import systemSettingsRoutes from "./routes/systemSettings";
import listeningStateRoutes from "./routes/listeningState";
import playbackStateRoutes from "./routes/playbackState";
import offlineRoutes from "./routes/offline";
import playlistsRoutes from "./routes/playlists";
import searchRoutes from "./routes/search";
import recommendationsRoutes from "./routes/recommendations";
import downloadsRoutes from "./routes/downloads";
import webhooksRoutes from "./routes/webhooks";
import audiobooksRoutes from "./routes/audiobooks";
import podcastsRoutes from "./routes/podcasts";
import artistsRoutes from "./routes/artists";
import soulseekRoutes from "./routes/soulseek";
import discoverRoutes from "./routes/discover";
import apiKeysRoutes from "./routes/apiKeys";
import mixesRoutes from "./routes/mixes";
import enrichmentRoutes from "./routes/enrichment";
import homepageRoutes from "./routes/homepage";
import deviceLinkRoutes from "./routes/deviceLink";
import spotifyRoutes from "./routes/spotify";
import notificationsRoutes from "./routes/notifications";
import browseRoutes from "./routes/browse";
import analysisRoutes from "./routes/analysis";
import releasesRoutes from "./routes/releases";
import { dataCacheService } from "./services/dataCache";
import { errorHandler } from "./middleware/errorHandler";
import {
authLimiter,
apiLimiter,
streamLimiter,
imageLimiter,
} from "./middleware/rateLimiter";
import swaggerUi from "swagger-ui-express";
import { swaggerSpec } from "./config/swagger";
const app = express();
// Middleware
app.use(
helmet({
crossOriginResourcePolicy: { policy: "cross-origin" },
})
);
app.use(
cors({
origin: (origin, callback) => {
// For self-hosted apps: allow all origins by default
// Users deploy on their own domains/IPs - we can't predict them
// Security is handled by authentication, not CORS
if (!origin) {
// Allow requests with no origin (same-origin, curl, etc.)
callback(null, true);
} else if (
config.allowedOrigins === true ||
config.nodeEnv === "development"
) {
// Explicitly allow all origins
callback(null, true);
} else if (
Array.isArray(config.allowedOrigins) &&
config.allowedOrigins.length > 0
) {
// Check against specific allowed origins if configured
if (config.allowedOrigins.includes(origin)) {
callback(null, true);
} else {
// For self-hosted: allow anyway but log it
// Users shouldn't have to configure CORS for their own app
console.log(
`[CORS] Origin ${origin} not in allowlist, allowing anyway (self-hosted)`
);
callback(null, true);
}
} else {
// No restrictions - allow all (self-hosted default)
callback(null, true);
}
},
credentials: true,
})
);
app.use(express.json({ limit: "1mb" })); // Increased from 100KB default to support large queue payloads
// Session
// Trust proxy for reverse proxy setups (nginx, traefik, etc.)
app.set("trust proxy", 1);
app.use(
session({
store: new RedisStore({
client: redisClient,
ttl: 7 * 24 * 60 * 60, // 7 days in seconds - must match cookie maxAge
}),
secret: config.sessionSecret,
resave: false,
saveUninitialized: false,
proxy: true, // Trust the reverse proxy
cookie: {
httpOnly: true,
// For self-hosted apps: allow HTTP access (common for LAN deployments)
// If behind HTTPS reverse proxy, the proxy should handle security
secure: false,
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
})
);
// Routes - All API routes prefixed with /api for clear separation from frontend
// Apply rate limiting to auth routes
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
app.use("/api/auth", authRoutes);
app.use("/api/onboarding", onboardingRoutes); // Public onboarding routes
// Apply general API rate limiting to all API routes
app.use("/api/api-keys", apiLimiter, apiKeysRoutes);
app.use("/api/device-link", apiLimiter, deviceLinkRoutes);
// NOTE: /api/library has its own rate limiting (imageLimiter for cover-art, apiLimiter for others)
app.use("/api/library", libraryRoutes);
app.use("/api/plays", apiLimiter, playsRoutes);
app.use("/api/settings", apiLimiter, settingsRoutes);
app.use("/api/system-settings", apiLimiter, systemSettingsRoutes);
app.use("/api/listening-state", apiLimiter, listeningStateRoutes);
app.use("/api/playback-state", playbackStateRoutes); // No rate limit - syncs frequently
app.use("/api/offline", apiLimiter, offlineRoutes);
app.use("/api/playlists", apiLimiter, playlistsRoutes);
app.use("/api/search", apiLimiter, searchRoutes);
app.use("/api/recommendations", apiLimiter, recommendationsRoutes);
app.use("/api/downloads", apiLimiter, downloadsRoutes);
app.use("/api/notifications", apiLimiter, notificationsRoutes);
app.use("/api/webhooks", webhooksRoutes); // Webhooks should not be rate limited
// NOTE: /api/audiobooks has its own rate limiting (imageLimiter for covers, apiLimiter for others)
app.use("/api/audiobooks", audiobooksRoutes);
app.use("/api/podcasts", apiLimiter, podcastsRoutes);
app.use("/api/artists", apiLimiter, artistsRoutes);
app.use("/api/soulseek", apiLimiter, soulseekRoutes);
app.use("/api/discover", apiLimiter, discoverRoutes);
app.use("/api/mixes", apiLimiter, mixesRoutes);
app.use("/api/enrichment", apiLimiter, enrichmentRoutes);
app.use("/api/homepage", apiLimiter, homepageRoutes);
app.use("/api/spotify", apiLimiter, spotifyRoutes);
app.use("/api/browse", apiLimiter, browseRoutes);
app.use("/api/analysis", apiLimiter, analysisRoutes);
app.use("/api/releases", apiLimiter, releasesRoutes);
// Health check (keep at root for simple container health checks)
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.get("/api/health", (req, res) => {
res.json({ status: "ok" });
});
// Swagger API Documentation
app.use(
"/api/docs",
swaggerUi.serve,
swaggerUi.setup(swaggerSpec, {
customCss: ".swagger-ui .topbar { display: none }",
customSiteTitle: "Lidify API Documentation",
})
);
// Serve raw OpenAPI spec
app.get("/api/docs.json", (req, res) => {
res.json(swaggerSpec);
});
// Error handler
app.use(errorHandler);
app.listen(config.port, "0.0.0.0", async () => {
console.log(
`Lidify API running on port ${config.port} (accessible on all network interfaces)`
);
// Enable slow query monitoring in development
if (config.nodeEnv === "development") {
const { enableSlowQueryMonitoring } = await import(
"./utils/queryMonitor"
);
enableSlowQueryMonitoring();
}
// Initialize music configuration (reads from SystemSettings)
const { initializeMusicConfig } = await import("./config");
await initializeMusicConfig();
// Initialize Bull queue workers
await import("./workers");
// Set up Bull Board dashboard
const { createBullBoard } = await import("@bull-board/api");
const { BullAdapter } = await import("@bull-board/api/bullAdapter");
const { ExpressAdapter } = await import("@bull-board/express");
const { scanQueue, discoverQueue, imageQueue } = await import(
"./workers/queues"
);
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath("/api/admin/queues");
createBullBoard({
queues: [
new BullAdapter(scanQueue),
new BullAdapter(discoverQueue),
new BullAdapter(imageQueue),
],
serverAdapter,
});
app.use("/api/admin/queues", serverAdapter.getRouter());
console.log("Bull Board dashboard available at /api/admin/queues");
// Note: Native library scanning is now triggered manually via POST /library/scan
// No automatic sync on startup - user must manually scan their music folder
// Enrichment worker enabled for OWNED content only
// - Background enrichment: Genres, MBIDs, similar artists for owned albums/artists
// - On-demand fetching: Artist images, bios when browsing (cached in Redis 7 days)
console.log(
"Background enrichment enabled for owned content (genres, MBIDs, etc.)"
);
// Warm up Redis cache from database on startup
// This populates Redis with existing artist images and album covers
// so first page loads are instant instead of waiting for cache population
dataCacheService.warmupCache().catch((err) => {
console.error("Cache warmup failed:", err);
});
// Podcast cache cleanup - runs daily to remove cached episodes older than 30 days
const { cleanupExpiredCache } = await import("./services/podcastDownload");
// Run cleanup on startup (async, don't block)
cleanupExpiredCache().catch((err) => {
console.error("Podcast cache cleanup failed:", err);
});
// Schedule daily cleanup (every 24 hours)
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
setInterval(() => {
cleanupExpiredCache().catch((err) => {
console.error("Scheduled podcast cache cleanup failed:", err);
});
}, TWENTY_FOUR_HOURS);
console.log("Podcast cache cleanup scheduled (daily, 30-day expiry)");
});
// Graceful shutdown handling
let isShuttingDown = false;
async function gracefulShutdown(signal: string) {
if (isShuttingDown) {
console.log("Shutdown already in progress...");
return;
}
isShuttingDown = true;
console.log(`\nReceived ${signal}. Starting graceful shutdown...`);
try {
// Shutdown workers (intervals, crons, queues)
const { shutdownWorkers } = await import("./workers");
await shutdownWorkers();
// Close Redis connection
console.log("Closing Redis connection...");
await redisClient.quit();
// Close Prisma connection
console.log("Closing database connection...");
await prisma.$disconnect();
console.log("Graceful shutdown complete");
process.exit(0);
} catch (error) {
console.error("Error during shutdown:", error);
process.exit(1);
}
}
// Handle termination signals
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
+320
View File
@@ -0,0 +1,320 @@
import { prisma } from "../utils/db";
import { getSystemSettings } from "../utils/systemSettings";
import {
cleanStuckDownloads,
getRecentCompletedDownloads,
} from "../services/lidarr";
import { scanQueue } from "../workers/queues";
import { simpleDownloadManager } from "../services/simpleDownloadManager";
class QueueCleanerService {
private isRunning = false;
private checkInterval = 30000; // 30 seconds when active
private emptyQueueChecks = 0;
private maxEmptyChecks = 3; // Stop after 3 consecutive empty checks
private timeoutId?: NodeJS.Timeout;
/**
* Start the polling loop
* Safe to call multiple times - won't create duplicate loops
*/
async start() {
if (this.isRunning) {
console.log(" Queue cleaner already running");
return;
}
this.isRunning = true;
this.emptyQueueChecks = 0;
console.log(" Queue cleaner started (checking every 30s)");
await this.runCleanup();
}
/**
* Stop the polling loop
*/
stop() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
this.isRunning = false;
console.log(" Queue cleaner stopped (queue empty)");
}
/**
* Main cleanup logic - runs every 30 seconds when active
*/
private async runCleanup() {
if (!this.isRunning) return;
try {
// Use getSystemSettings() to get decrypted API key
const settings = await getSystemSettings();
if (!settings?.lidarrUrl || !settings?.lidarrApiKey) {
console.log(" Lidarr not configured, stopping queue cleaner");
this.stop();
return;
}
// PART 0: Check for stale downloads (timed out)
const staleCount =
await simpleDownloadManager.markStaleJobsAsFailed();
if (staleCount > 0) {
console.log(`⏰ Cleaned up ${staleCount} stale download(s)`);
this.emptyQueueChecks = 0; // Reset counter
}
// PART 0.25: Reconcile processing jobs with Lidarr (fix missed webhooks)
const reconcileResult =
await simpleDownloadManager.reconcileWithLidarr();
if (reconcileResult.reconciled > 0) {
console.log(
`✓ Reconciled ${reconcileResult.reconciled} job(s) with Lidarr`
);
this.emptyQueueChecks = 0; // Reset counter
}
// PART 0.5: Check for stuck discovery batches (batch-level timeout)
const { discoverWeeklyService } = await import(
"../services/discoverWeekly"
);
const stuckBatchCount =
await discoverWeeklyService.checkStuckBatches();
if (stuckBatchCount > 0) {
console.log(
`⏰ Force-completed ${stuckBatchCount} stuck discovery batch(es)`
);
this.emptyQueueChecks = 0; // Reset counter
}
// PART 1: Check for stuck downloads needing blocklist + retry
const cleanResult = await cleanStuckDownloads(
settings.lidarrUrl,
settings.lidarrApiKey
);
if (cleanResult.removed > 0) {
console.log(
`[CLEANUP] Removed ${cleanResult.removed} stuck download(s) - searching for alternatives`
);
this.emptyQueueChecks = 0; // Reset counter - queue had activity
// Update retry count for jobs that might match these titles
// Note: This is a best-effort match since we only have the title
for (const title of cleanResult.items) {
// Try to extract artist and album from the title
// Typical format: "Artist - Album" or "Artist - Album (Year)"
const parts = title.split(" - ");
if (parts.length >= 2) {
const artistName = parts[0].trim();
const albumPart = parts.slice(1).join(" - ").trim();
// Remove year in parentheses if present
const albumTitle = albumPart
.replace(/\s*\(\d{4}\)\s*$/, "")
.trim();
// Find matching processing jobs
const matchingJobs = await prisma.downloadJob.findMany({
where: {
status: "processing",
subject: {
contains: albumTitle,
mode: "insensitive",
},
},
});
for (const job of matchingJobs) {
const metadata = (job.metadata as any) || {};
const currentRetryCount = metadata.retryCount || 0;
await prisma.downloadJob.update({
where: { id: job.id },
data: {
metadata: {
...metadata,
retryCount: currentRetryCount + 1,
lastError:
"Import failed - searching for alternative release",
},
},
});
console.log(
` Updated job ${job.id}: retry ${
currentRetryCount + 1
}`
);
}
}
}
}
// PART 2: Check for completed downloads (missing webhooks)
const completedDownloads = await getRecentCompletedDownloads(
settings.lidarrUrl,
settings.lidarrApiKey,
5 // Only check last 5 minutes since we're running frequently
);
let recoveredCount = 0;
let skippedCount = 0;
for (const download of completedDownloads) {
// Skip records without album data (can happen with certain event types)
if (!download.album?.foreignAlbumId) {
skippedCount++;
continue;
}
const mbid = download.album.foreignAlbumId;
// Find matching job(s) in database by MBID or downloadId
const orphanedJobs = await prisma.downloadJob.findMany({
where: {
status: { in: ["processing", "pending"] },
OR: [
{ targetMbid: mbid },
{ lidarrRef: download.downloadId },
],
},
});
if (orphanedJobs.length > 0) {
const artistName =
download.artist?.name || "Unknown Artist";
const albumTitle = download.album?.title || "Unknown Album";
console.log(
`Recovered orphaned job: ${artistName} - ${albumTitle}`
);
console.log(` Download ID: ${download.downloadId}`);
this.emptyQueueChecks = 0; // Reset counter - found work to do
recoveredCount += orphanedJobs.length;
// Mark all matching jobs as complete
await prisma.downloadJob.updateMany({
where: {
id: {
in: orphanedJobs.map(
(j: { id: string }) => j.id
),
},
},
data: {
status: "completed",
completedAt: new Date(),
},
});
// Check batch completion for any Discovery jobs
// Use proper checkBatchCompletion() instead of manual logic
const discoveryBatchIds = new Set<string>();
for (const job of orphanedJobs) {
if (job.discoveryBatchId) {
discoveryBatchIds.add(job.discoveryBatchId);
}
}
if (discoveryBatchIds.size > 0) {
const { discoverWeeklyService } = await import(
"../services/discoverWeekly"
);
for (const batchId of discoveryBatchIds) {
console.log(
` Checking Discovery batch completion: ${batchId}`
);
await discoverWeeklyService.checkBatchCompletion(
batchId
);
}
}
// Trigger library scan for non-discovery jobs
const nonDiscoveryJobs = orphanedJobs.filter(
(j: { discoveryBatchId: string | null }) =>
!j.discoveryBatchId
);
if (nonDiscoveryJobs.length > 0) {
console.log(
` Triggering library scan for recovered job(s)...`
);
await scanQueue.add("scan", {
type: "full",
source: "queue-cleaner-recovery",
});
}
}
}
if (recoveredCount > 0) {
console.log(`Recovered ${recoveredCount} orphaned job(s)`);
}
// Only log skipped count occasionally to reduce noise
if (skippedCount > 0 && this.emptyQueueChecks === 0) {
console.log(
` (Skipped ${skippedCount} incomplete download records)`
);
}
// PART 3: Check if we should stop (no activity)
const activeJobs = await prisma.downloadJob.count({
where: {
status: { in: ["pending", "processing"] },
},
});
const hadActivity =
cleanResult.removed > 0 || recoveredCount > 0 || activeJobs > 0;
if (!hadActivity) {
this.emptyQueueChecks++;
console.log(
` Queue empty (${this.emptyQueueChecks}/${this.maxEmptyChecks})`
);
if (this.emptyQueueChecks >= this.maxEmptyChecks) {
console.log(
` No activity for ${this.maxEmptyChecks} checks - stopping cleaner`
);
this.stop();
return;
}
} else {
this.emptyQueueChecks = 0;
}
// Schedule next check
this.timeoutId = setTimeout(
() => this.runCleanup(),
this.checkInterval
);
} catch (error) {
console.error(" Queue cleanup error:", error);
// Still schedule next check even on error
this.timeoutId = setTimeout(
() => this.runCleanup(),
this.checkInterval
);
}
}
/**
* Get current status (for debugging/monitoring)
*/
getStatus() {
return {
isRunning: this.isRunning,
emptyQueueChecks: this.emptyQueueChecks,
nextCheckIn: this.isRunning
? `${this.checkInterval / 1000}s`
: "stopped",
};
}
}
// Export singleton instance
export const queueCleaner = new QueueCleanerService();
+205
View File
@@ -0,0 +1,205 @@
import { Request, Response, NextFunction } from "express";
import { prisma } from "../utils/db";
import jwt from "jsonwebtoken";
// JWT_SECRET is required - SESSION_SECRET is used as fallback since docker-entrypoint.sh generates it
const JWT_SECRET = process.env.JWT_SECRET || process.env.SESSION_SECRET;
if (!JWT_SECRET) {
throw new Error(
"JWT_SECRET or SESSION_SECRET environment variable is required for authentication"
);
}
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username: string;
role: string;
};
}
}
}
export interface JWTPayload {
userId: string;
username: string;
role: string;
}
export function generateToken(user: { id: string; username: string; role: string }): string {
return jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: "30d" }
);
}
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
) {
// First, check session-based auth (primary method)
if (req.session?.userId) {
try {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
select: { id: true, username: true, role: true },
});
if (user) {
req.user = user;
return next();
}
} catch (error) {
console.error("Session auth error:", error);
}
}
// Check for API key in X-API-Key header (for mobile/external apps)
const apiKey = req.headers["x-api-key"] as string;
if (apiKey) {
try {
const apiKeyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
include: { user: { select: { id: true, username: true, role: true } } },
});
if (apiKeyRecord && apiKeyRecord.user) {
// Update last used timestamp (async, don't block)
prisma.apiKey.update({
where: { id: apiKeyRecord.id },
data: { lastUsed: new Date() },
}).catch(() => {}); // Ignore errors on lastUsed update
req.user = apiKeyRecord.user;
return next();
}
} catch (error) {
console.error("API key auth error:", error);
}
}
// Fallback: check JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true },
});
if (user) {
req.user = user;
return next();
}
} catch (error) {
// Token invalid, continue to error
}
}
return res.status(401).json({ error: "Not authenticated" });
}
export async function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.user || req.user.role !== "admin") {
return res.status(403).json({ error: "Admin access required" });
}
next();
}
// For streaming URLs that may use query params or need special handling
export async function requireAuthOrToken(
req: Request,
res: Response,
next: NextFunction
) {
// First, check session-based auth (primary method for web)
if (req.session?.userId) {
try {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
select: { id: true, username: true, role: true },
});
if (user) {
req.user = user;
return next();
}
} catch (error) {
console.error("Session auth error:", error);
}
}
// Check for API key in X-API-Key header (for mobile/external apps)
const apiKey = req.headers["x-api-key"] as string;
if (apiKey) {
try {
const apiKeyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
include: { user: { select: { id: true, username: true, role: true } } },
});
if (apiKeyRecord && apiKeyRecord.user) {
// Update last used timestamp (async, don't block)
prisma.apiKey.update({
where: { id: apiKeyRecord.id },
data: { lastUsed: new Date() },
}).catch(() => {}); // Ignore errors on lastUsed update
req.user = apiKeyRecord.user;
return next();
}
} catch (error) {
console.error("API key auth error:", error);
}
}
// Check for token in query param (for streaming URLs from audio elements)
const tokenParam = req.query.token as string;
if (tokenParam) {
try {
const decoded = jwt.verify(tokenParam, JWT_SECRET) as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true },
});
if (user) {
req.user = user;
return next();
}
} catch (error) {
// Token invalid, try other methods
}
}
// Fallback: check JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { id: true, username: true, role: true },
});
if (user) {
req.user = user;
return next();
}
} catch (error) {
// Token invalid, continue to error
}
}
return res.status(401).json({ error: "Not authenticated" });
}
+11
View File
@@ -0,0 +1,11 @@
import { Request, Response, NextFunction } from "express";
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
console.error(err.stack);
res.status(500).json({ error: "Internal server error" });
}
+58
View File
@@ -0,0 +1,58 @@
import rateLimit from "express-rate-limit";
// General API rate limiter (5000 req/minute per IP)
// This is for a single-user self-hosted app, so limits should be VERY high
// Only exists to prevent infinite loops or bugs from DOS'ing the server
export const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 5000, // Very high limit - personal app, not a public API
message: "Too many requests from this IP, please try again later.",
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
skip: (req) => {
// Never rate limit streaming or status polling endpoints
return req.path.includes("/stream") ||
req.path.includes("/status") ||
req.path.includes("/health");
},
});
// Auth limiter for login endpoints (20 attempts/15min per IP)
// More lenient for self-hosted apps where users may have password manager issues
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // Increased from 5 for self-hosted environments
skipSuccessfulRequests: true, // Don't count successful requests
message: "Too many login attempts, please try again in 15 minutes.",
standardHeaders: true,
legacyHeaders: false,
});
// Media streaming limiter (higher limit: 200 streams/minute)
export const streamLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 200, // Allow 200 stream requests per minute
message: "Too many streaming requests, please slow down.",
standardHeaders: true,
legacyHeaders: false,
});
// Image/Cover art limiter (very high limit: 500 req/minute)
// This is for image proxying - not a security risk, just bandwidth
export const imageLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 500, // Allow 500 image requests per minute (high volume pages need this)
message: "Too many image requests, please slow down.",
standardHeaders: true,
legacyHeaders: false,
});
// Download limiter (100 req/minute)
// Users might download entire discographies, so this needs to be reasonable
export const downloadLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100,
message: "Too many download requests, please try again later.",
standardHeaders: true,
legacyHeaders: false,
});
+293
View File
@@ -0,0 +1,293 @@
import { Router } from "express";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
import { requireAuth, requireAdmin } from "../middleware/auth";
const router = Router();
// Redis queue key for audio analysis
const ANALYSIS_QUEUE = "audio:analysis:queue";
/**
* GET /api/analysis/status
* Get audio analysis status and progress
*/
router.get("/status", requireAuth, async (req, res) => {
try {
// Get counts by status
const statusCounts = await prisma.track.groupBy({
by: ["analysisStatus"],
_count: true,
});
const total = statusCounts.reduce((sum, s) => sum + s._count, 0);
const completed = statusCounts.find(s => s.analysisStatus === "completed")?._count || 0;
const failed = statusCounts.find(s => s.analysisStatus === "failed")?._count || 0;
const processing = statusCounts.find(s => s.analysisStatus === "processing")?._count || 0;
const pending = statusCounts.find(s => s.analysisStatus === "pending")?._count || 0;
// Get queue length from Redis
const queueLength = await redisClient.lLen(ANALYSIS_QUEUE);
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
res.json({
total,
completed,
failed,
processing,
pending,
queueLength,
progress,
isComplete: pending === 0 && processing === 0 && queueLength === 0,
});
} catch (error: any) {
console.error("Analysis status error:", error);
res.status(500).json({ error: "Failed to get analysis status" });
}
});
/**
* POST /api/analysis/start
* Start audio analysis for pending tracks (admin only)
*/
router.post("/start", requireAuth, requireAdmin, async (req, res) => {
try {
const { limit = 100, priority = "recent" } = req.body;
// Find pending tracks
const tracks = await prisma.track.findMany({
where: {
analysisStatus: "pending",
},
select: {
id: true,
filePath: true,
},
orderBy: priority === "recent"
? { fileModified: "desc" }
: { title: "asc" },
take: Math.min(limit, 1000),
});
if (tracks.length === 0) {
return res.json({
message: "No pending tracks to analyze",
queued: 0,
});
}
// Queue tracks for analysis
const pipeline = redisClient.multi();
for (const track of tracks) {
pipeline.rPush(ANALYSIS_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
}));
}
await pipeline.exec();
console.log(`Queued ${tracks.length} tracks for audio analysis`);
res.json({
message: `Queued ${tracks.length} tracks for analysis`,
queued: tracks.length,
});
} catch (error: any) {
console.error("Analysis start error:", error);
res.status(500).json({ error: "Failed to start analysis" });
}
});
/**
* POST /api/analysis/retry-failed
* Retry failed analysis jobs (admin only)
*/
router.post("/retry-failed", requireAuth, requireAdmin, async (req, res) => {
try {
// Reset failed tracks to pending
const result = await prisma.track.updateMany({
where: {
analysisStatus: "failed",
},
data: {
analysisStatus: "pending",
analysisError: null,
},
});
res.json({
message: `Reset ${result.count} failed tracks to pending`,
reset: result.count,
});
} catch (error: any) {
console.error("Retry failed error:", error);
res.status(500).json({ error: "Failed to retry analysis" });
}
});
/**
* POST /api/analysis/analyze/:trackId
* Queue a specific track for analysis
*/
router.post("/analyze/:trackId", requireAuth, async (req, res) => {
try {
const { trackId } = req.params;
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
filePath: true,
analysisStatus: true,
},
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
// Queue for analysis
await redisClient.rPush(ANALYSIS_QUEUE, JSON.stringify({
trackId: track.id,
filePath: track.filePath,
}));
// Mark as pending if not already
if (track.analysisStatus !== "processing") {
await prisma.track.update({
where: { id: trackId },
data: { analysisStatus: "pending" },
});
}
res.json({
message: "Track queued for analysis",
trackId,
});
} catch (error: any) {
console.error("Analyze track error:", error);
res.status(500).json({ error: "Failed to queue track for analysis" });
}
});
/**
* GET /api/analysis/track/:trackId
* Get analysis data for a specific track
*/
router.get("/track/:trackId", requireAuth, async (req, res) => {
try {
const { trackId } = req.params;
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
title: true,
analysisStatus: true,
analysisError: true,
analyzedAt: true,
analysisVersion: true,
bpm: true,
beatsCount: true,
key: true,
keyScale: true,
keyStrength: true,
energy: true,
loudness: true,
dynamicRange: true,
danceability: true,
valence: true,
arousal: true,
instrumentalness: true,
acousticness: true,
speechiness: true,
moodTags: true,
essentiaGenres: true,
lastfmTags: true,
},
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
res.json(track);
} catch (error: any) {
console.error("Get track analysis error:", error);
res.status(500).json({ error: "Failed to get track analysis" });
}
});
/**
* GET /api/analysis/features
* Get aggregated feature statistics for the library
*/
router.get("/features", requireAuth, async (req, res) => {
try {
// Get analyzed tracks
const analyzed = await prisma.track.findMany({
where: {
analysisStatus: "completed",
bpm: { not: null },
},
select: {
bpm: true,
energy: true,
danceability: true,
valence: true,
keyScale: true,
},
});
if (analyzed.length === 0) {
return res.json({
count: 0,
averages: null,
distributions: null,
});
}
// Calculate averages
const avgBpm = analyzed.reduce((sum, t) => sum + (t.bpm || 0), 0) / analyzed.length;
const avgEnergy = analyzed.reduce((sum, t) => sum + (t.energy || 0), 0) / analyzed.length;
const avgDanceability = analyzed.reduce((sum, t) => sum + (t.danceability || 0), 0) / analyzed.length;
const avgValence = analyzed.reduce((sum, t) => sum + (t.valence || 0), 0) / analyzed.length;
// Key distribution
const majorCount = analyzed.filter(t => t.keyScale === "major").length;
const minorCount = analyzed.filter(t => t.keyScale === "minor").length;
// BPM distribution (buckets)
const bpmBuckets = {
slow: analyzed.filter(t => (t.bpm || 0) < 90).length,
moderate: analyzed.filter(t => (t.bpm || 0) >= 90 && (t.bpm || 0) < 120).length,
upbeat: analyzed.filter(t => (t.bpm || 0) >= 120 && (t.bpm || 0) < 150).length,
fast: analyzed.filter(t => (t.bpm || 0) >= 150).length,
};
res.json({
count: analyzed.length,
averages: {
bpm: Math.round(avgBpm),
energy: Math.round(avgEnergy * 100) / 100,
danceability: Math.round(avgDanceability * 100) / 100,
valence: Math.round(avgValence * 100) / 100,
},
distributions: {
key: { major: majorCount, minor: minorCount },
bpm: bpmBuckets,
},
});
} catch (error: any) {
console.error("Get features error:", error);
res.status(500).json({ error: "Failed to get feature statistics" });
}
});
export default router;
+231
View File
@@ -0,0 +1,231 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import crypto from "crypto";
const router = Router();
// All API key routes require authentication (session-based)
router.use(requireAuth);
/**
* @openapi
* /api-keys:
* post:
* summary: Create a new API key for mobile/external authentication
* tags: [API Keys]
* security:
* - sessionAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - deviceName
* properties:
* deviceName:
* type: string
* description: Name of the device (e.g., "iPhone 14", "Android Tablet")
* example: "iPhone 14"
* responses:
* 201:
* description: API key created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* description: The generated API key (64-character hex string)
* example: "a1b2c3d4e5f6..."
* name:
* type: string
* example: "iPhone 14"
* createdAt:
* type: string
* format: date-time
* message:
* type: string
* example: "API key created successfully. Save this key - you won't see it again!"
* 400:
* description: Invalid request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post("/", async (req, res) => {
try {
const { deviceName } = req.body;
if (!deviceName || deviceName.trim().length === 0) {
return res.status(400).json({ error: "Device name is required" });
}
// Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
const userId = req.user?.id || req.session?.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// Generate a secure random API key (32 bytes = 64 hex chars)
const apiKeyValue = crypto.randomBytes(32).toString("hex");
const apiKey = await prisma.apiKey.create({
data: {
userId,
name: deviceName.trim(),
key: apiKeyValue,
},
});
console.log(`API key created for user ${userId}: ${deviceName}`);
res.status(201).json({
apiKey: apiKey.key,
name: apiKey.name,
createdAt: apiKey.createdAt,
message:
"API key created successfully. Save this key - you won't see it again!",
});
} catch (error) {
console.error("Create API key error:", error);
res.status(500).json({ error: "Failed to create API key" });
}
});
/**
* @openapi
* /api-keys:
* get:
* summary: List all API keys for the current user
* tags: [API Keys]
* security:
* - sessionAuth: []
* responses:
* 200:
* description: List of API keys (without the actual key values for security)
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKeys:
* type: array
* items:
* $ref: '#/components/schemas/ApiKey'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/", async (req, res) => {
try {
// Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
const userId = req.user?.id || req.session?.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const keys = await prisma.apiKey.findMany({
where: { userId },
select: {
id: true,
name: true,
lastUsed: true,
createdAt: true,
// Don't return the actual key for security!
},
orderBy: { createdAt: "desc" },
});
res.json({ apiKeys: keys });
} catch (error) {
console.error("List API keys error:", error);
res.status(500).json({ error: "Failed to list API keys" });
}
});
/**
* @openapi
* /api-keys/{id}:
* delete:
* summary: Revoke an API key
* tags: [API Keys]
* security:
* - sessionAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The API key ID
* responses:
* 200:
* description: API key revoked successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: "API key revoked successfully"
* 404:
* description: API key not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.delete("/:id", async (req, res) => {
try {
// Use req.user.id (set by requireAuth middleware) - supports both session and JWT auth
const userId = req.user?.id || req.session?.userId;
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const keyId = req.params.id;
// Only allow users to delete their own keys
const deleted = await prisma.apiKey.deleteMany({
where: {
id: keyId,
userId,
},
});
if (deleted.count === 0) {
return res
.status(404)
.json({ error: "API key not found or already deleted" });
}
console.log(`API key ${keyId} revoked by user ${userId}`);
res.json({ message: "API key revoked successfully" });
} catch (error) {
console.error("Delete API key error:", error);
res.status(500).json({ error: "Failed to revoke API key" });
}
});
export default router;
+566
View File
@@ -0,0 +1,566 @@
import { Router } from "express";
import { lastFmService } from "../services/lastfm";
import { musicBrainzService } from "../services/musicbrainz";
import { fanartService } from "../services/fanart";
import { deezerService } from "../services/deezer";
import { redisClient } from "../utils/redis";
const router = Router();
// Cache TTL for discovery content (shorter since it's not owned)
const DISCOVERY_CACHE_TTL = 24 * 60 * 60; // 24 hours
// GET /artists/preview/:artistName/:trackTitle - Get Deezer preview URL for a track
router.get("/preview/:artistName/:trackTitle", async (req, res) => {
try {
const { artistName, trackTitle } = req.params;
const decodedArtist = decodeURIComponent(artistName);
const decodedTrack = decodeURIComponent(trackTitle);
console.log(
`Getting preview for "${decodedTrack}" by ${decodedArtist}`
);
const previewUrl = await deezerService.getTrackPreview(
decodedArtist,
decodedTrack
);
if (previewUrl) {
res.json({ previewUrl });
} else {
res.status(404).json({ error: "Preview not found" });
}
} catch (error: any) {
console.error("Preview fetch error:", error);
res.status(500).json({
error: "Failed to fetch preview",
message: error.message,
});
}
});
// GET /artists/discover/:nameOrMbid - Get artist details for discovery (not in library yet)
router.get("/discover/:nameOrMbid", async (req, res) => {
try {
const { nameOrMbid } = req.params;
// Check Redis cache first for discovery content
const cacheKey = `discovery:artist:${nameOrMbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[Discovery] Cache hit for artist: ${nameOrMbid}`);
return res.json(JSON.parse(cached));
}
} catch (err) {
// Redis errors are non-critical
}
// Check if it's an MBID (UUID format) or name
const isMbid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
nameOrMbid
);
let mbid: string | null = isMbid ? nameOrMbid : null;
let artistName: string = isMbid ? "" : decodeURIComponent(nameOrMbid);
// If we have a name but no MBID, search for it
if (!mbid && artistName) {
const mbResults = await musicBrainzService.searchArtist(
artistName,
1
);
if (mbResults.length > 0) {
mbid = mbResults[0].id;
artistName = mbResults[0].name;
}
}
// If we have MBID but no name, get it from MusicBrainz
if (mbid && !artistName) {
const mbArtist = await musicBrainzService.getArtist(mbid);
artistName = mbArtist.name;
}
if (!artistName) {
return res.status(404).json({ error: "Artist not found" });
}
// Get artist info from Last.fm
const lastFmInfo = await lastFmService.getArtistInfo(
artistName,
mbid || undefined
);
// Filter out generic "multiple artists" biographies from Last.fm
// These occur when Last.fm groups artists with the same name
let bio = lastFmInfo?.bio?.summary || null;
if (bio) {
const lowerBio = bio.toLowerCase();
if (
(lowerBio.includes("there are") &&
(lowerBio.includes("artist") ||
lowerBio.includes("band")) &&
lowerBio.includes("with the name")) ||
lowerBio.includes("there is more than one artist") ||
lowerBio.includes("multiple artists")
) {
// This is a disambiguation page - don't show it
console.log(
` Filtered out disambiguation biography for ${artistName}`
);
bio = null;
}
}
// Get top tracks from Last.fm
let topTracks: any[] = [];
if (mbid || artistName) {
try {
topTracks = await lastFmService.getArtistTopTracks(
mbid || "",
artistName,
10
);
} catch (error) {
console.log(`Failed to get top tracks for ${artistName}`);
}
}
// Get artist image
let image = null;
// Try Fanart.tv first (if we have MBID)
if (mbid) {
try {
image = await fanartService.getArtistImage(mbid);
console.log(`Fanart.tv image for ${artistName}`);
} catch (error) {
console.log(
`✗ Failed to get Fanart.tv image for ${artistName}`
);
}
}
// Fallback to Deezer
if (!image) {
try {
image = await deezerService.getArtistImage(artistName);
if (image) {
console.log(`Deezer image for ${artistName}`);
}
} catch (error) {
console.log(`✗ Failed to get Deezer image for ${artistName}`);
}
}
// Fallback to Last.fm (but filter placeholders)
if (!image && lastFmInfo?.image) {
const lastFmImage = lastFmService.getBestImage(lastFmInfo.image);
// Filter out Last.fm placeholder
if (
lastFmImage &&
!lastFmImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
) {
image = lastFmImage;
console.log(`Last.fm image for ${artistName}`);
} else {
console.log(`✗ Last.fm returned placeholder for ${artistName}`);
}
}
// Get discography from MusicBrainz
let albums: any[] = [];
if (mbid) {
try {
const releaseGroups = await musicBrainzService.getReleaseGroups(
mbid
);
// Filter albums - only show studio albums and EPs
// Exclude live albums, compilations, soundtracks, remixes, etc.
const filteredReleaseGroups = releaseGroups.filter(
(rg: any) => {
// Must be Album or EP
const isPrimaryType =
rg["primary-type"] === "Album" ||
rg["primary-type"] === "EP";
if (!isPrimaryType) return false;
// Exclude secondary types (live, compilation, soundtrack, remix, etc.)
const secondaryTypes = rg["secondary-types"] || [];
const hasExcludedType = secondaryTypes.some(
(type: string) =>
[
"Live",
"Compilation",
"Soundtrack",
"Remix",
"DJ-mix",
"Mixtape/Street",
].includes(type)
);
return !hasExcludedType;
}
);
// Process albums with Deezer fallback
albums = await Promise.all(
filteredReleaseGroups.map(async (rg: any) => {
// Default to Cover Art Archive URL
let coverUrl = `https://coverartarchive.org/release-group/${rg.id}/front-500`;
// For first 10 albums, try Deezer as fallback if Cover Art Archive doesn't have it
// (to avoid too many requests)
const index = filteredReleaseGroups.indexOf(rg);
if (index < 10) {
try {
const response = await fetch(coverUrl, {
method: "HEAD",
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
// Cover Art Archive doesn't have it, try Deezer
const deezerCover =
await deezerService.getAlbumCover(
artistName,
rg.title
);
if (deezerCover) {
coverUrl = deezerCover;
}
}
} catch (error) {
// Silently fail and keep Cover Art Archive URL
}
}
return {
id: rg.id, // MBID - used for linking
rgMbid: rg.id, // Release group MBID - used for downloads
mbid: rg.id, // Fallback MBID
title: rg.title,
type: rg["primary-type"],
year: rg["first-release-date"]
? parseInt(
rg["first-release-date"].substring(0, 4)
)
: null,
releaseDate: rg["first-release-date"] || null,
coverUrl,
owned: false, // Discovery albums are never owned
};
})
);
// Sort albums
albums.sort((a: any, b: any) => {
// Sort by year descending (newest first)
if (a.year && b.year) return b.year - a.year;
if (a.year) return -1;
if (b.year) return 1;
return 0;
});
} catch (error) {
console.error(
`Failed to get discography for ${artistName}:`,
error
);
}
}
// Get similar artists from Last.fm and fetch images
const similarArtistsRaw = lastFmInfo?.similar?.artist || [];
const similarArtists = await Promise.all(
similarArtistsRaw.slice(0, 10).map(async (artist: any) => {
const similarImage = artist.image?.find(
(img: any) => img.size === "large"
)?.[" #text"];
let image = null;
// Try Fanart.tv first (if we have MBID)
if (artist.mbid) {
try {
image = await fanartService.getArtistImage(artist.mbid);
} catch (error) {
// Silently fail
}
}
// Fallback to Deezer
if (!image) {
try {
const deezerImage = await deezerService.getArtistImage(
artist.name
);
if (deezerImage) {
image = deezerImage;
}
} catch (error) {
// Silently fail
}
}
// Last fallback to Last.fm (but filter placeholders)
if (
!image &&
similarImage &&
!similarImage.includes("2a96cbd8b46e442fc41c2b86b821562f")
) {
image = similarImage;
}
return {
id: artist.mbid || artist.name,
name: artist.name,
mbid: artist.mbid || null,
url: artist.url,
image,
};
})
);
const response = {
mbid,
name: artistName,
image,
bio, // Use filtered bio instead of raw Last.fm bio
summary: bio, // Alias for consistency
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency
listeners: parseInt(lastFmInfo?.stats?.listeners || "0"),
playcount: parseInt(lastFmInfo?.stats?.playcount || "0"),
url: lastFmInfo?.url || null,
albums: albums.map((album) => ({ ...album, owned: false })), // Mark all as not owned
topTracks: topTracks.map((track) => ({
id: `lastfm-${mbid || artistName}-${track.name}`,
title: track.name,
playCount: parseInt(track.playcount || "0"),
listeners: parseInt(track.listeners || "0"),
duration: parseInt(track.duration || "0"),
url: track.url,
album: { title: track.album?.["#text"] || "Unknown Album" },
})),
similarArtists,
};
// Cache discovery response for 24 hours
try {
await redisClient.setEx(
cacheKey,
DISCOVERY_CACHE_TTL,
JSON.stringify(response)
);
console.log(`[Discovery] Cached artist: ${artistName}`);
} catch (err) {
// Redis errors are non-critical
}
res.json(response);
} catch (error: any) {
console.error("Artist discovery error:", error);
res.status(500).json({
error: "Failed to fetch artist details",
message: error.message,
});
}
});
// GET /artists/album/:mbid - Get album details for discovery (not in library yet)
router.get("/album/:mbid", async (req, res) => {
try {
const { mbid } = req.params;
// Check Redis cache first for discovery content
const cacheKey = `discovery:album:${mbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[Discovery] Cache hit for album: ${mbid}`);
return res.json(JSON.parse(cached));
}
} catch (err) {
// Redis errors are non-critical
}
let releaseGroup: any = null;
let release: any = null;
let releaseGroupId: string = mbid;
// Try as release-group first, then as release
try {
releaseGroup = await musicBrainzService.getReleaseGroup(mbid);
} catch (error: any) {
// If 404, try as a release instead
if (error.response?.status === 404) {
console.log(
`${mbid} is not a release-group, trying as release...`
);
release = await musicBrainzService.getRelease(mbid);
releaseGroupId = release["release-group"]?.id || mbid;
// Now get the release group to get the type and first-release-date
if (releaseGroupId) {
try {
releaseGroup = await musicBrainzService.getReleaseGroup(
releaseGroupId
);
} catch (err) {
console.error(
`Failed to get release-group ${releaseGroupId}`
);
}
}
} else {
throw error;
}
}
if (!releaseGroup && !release) {
return res.status(404).json({ error: "Album not found" });
}
// Get the artist name and MBID from either release-group or release
const artistCredit =
releaseGroup?.["artist-credit"] || release?.["artist-credit"];
const artistName = artistCredit?.[0]?.name || "Unknown Artist";
const artistMbid = artistCredit?.[0]?.artist?.id;
const albumTitle = releaseGroup?.title || release?.title;
// Get album info from Last.fm
let lastFmInfo = null;
try {
lastFmInfo = await lastFmService.getAlbumInfo(
artistName,
albumTitle
);
} catch (error) {
console.log(`Failed to get Last.fm info for ${albumTitle}`);
}
// Get tracks - if we have release, use it directly; otherwise get first release from group
let tracks: any[] = [];
if (release) {
tracks = release.media?.[0]?.tracks || [];
} else if (releaseGroup?.releases && releaseGroup.releases.length > 0) {
const firstRelease = releaseGroup.releases[0];
try {
const releaseDetails = await musicBrainzService.getRelease(
firstRelease.id
);
tracks = releaseDetails.media?.[0]?.tracks || [];
} catch (error) {
console.error(
`Failed to get tracks for release ${firstRelease.id}`
);
}
}
// Get album cover art - try Cover Art Archive first
let coverUrl = null;
let coverArtUrl = `https://coverartarchive.org/release/${mbid}/front-500`;
if (!release) {
coverArtUrl = `https://coverartarchive.org/release-group/${releaseGroupId}/front-500`;
}
// Check if Cover Art Archive actually has the image
try {
const response = await fetch(coverArtUrl, { method: "HEAD" });
if (response.ok) {
coverUrl = coverArtUrl;
console.log(`Cover Art Archive has cover for ${albumTitle}`);
} else {
console.log(
`✗ Cover Art Archive 404 for ${albumTitle}, trying Deezer...`
);
}
} catch (error) {
console.log(
`✗ Cover Art Archive check failed for ${albumTitle}, trying Deezer...`
);
}
// Fallback to Deezer if Cover Art Archive doesn't have it
if (!coverUrl) {
try {
const deezerCover = await deezerService.getAlbumCover(
artistName,
albumTitle
);
if (deezerCover) {
coverUrl = deezerCover;
console.log(`Deezer has cover for ${albumTitle}`);
} else {
// Final fallback to Cover Art Archive URL (might 404, but better than nothing)
coverUrl = coverArtUrl;
}
} catch (error) {
console.log(`✗ Deezer lookup failed for ${albumTitle}`);
// Final fallback to Cover Art Archive URL
coverUrl = coverArtUrl;
}
}
// Format response
const releaseMbid = release?.id || null;
const response = {
id: releaseGroupId,
rgMbid: releaseGroupId,
mbid: releaseMbid || releaseGroupId,
releaseMbid,
title: albumTitle,
artist: {
name: artistName,
id: artistMbid || artistName,
mbid: artistMbid,
},
year: releaseGroup?.["first-release-date"]
? parseInt(releaseGroup["first-release-date"].substring(0, 4))
: release?.date
? parseInt(release.date.substring(0, 4))
: null,
type: releaseGroup?.["primary-type"] || "Album",
coverUrl,
coverArt: coverUrl, // Alias for compatibility
bio: lastFmInfo?.wiki?.summary || null,
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
tracks: tracks.map((track: any, index: number) => ({
id: `mb-${releaseGroupId}-${track.id || index}`,
title: track.title,
trackNo: track.position || index + 1,
duration: track.length ? Math.floor(track.length / 1000) : 0,
artist: { name: artistName },
})),
similarAlbums: [], // Similar album recommendations not yet implemented
owned: false,
source: "discovery",
};
// Cache discovery response for 24 hours
try {
await redisClient.setEx(
cacheKey,
DISCOVERY_CACHE_TTL,
JSON.stringify(response)
);
console.log(`[Discovery] Cached album: ${albumTitle}`);
} catch (err) {
// Redis errors are non-critical
}
res.json(response);
} catch (error: any) {
console.error("Album discovery error:", error);
res.status(500).json({
error: "Failed to fetch album details",
message: error.message,
});
}
});
export default router;
+907
View File
@@ -0,0 +1,907 @@
import { Router } from "express";
import { audiobookshelfService } from "../services/audiobookshelf";
import { audiobookCacheService } from "../services/audiobookCache";
import { prisma } from "../utils/db";
import { requireAuthOrToken } from "../middleware/auth";
import { imageLimiter, apiLimiter } from "../middleware/rateLimiter";
const router = Router();
/**
* GET /audiobooks/continue-listening
* Get audiobooks the user is currently listening to (for "Continue Listening" section)
* NOTE: This must come BEFORE the /:id route to avoid matching "continue-listening" as an ID
*/
router.get(
"/continue-listening",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json([]);
}
const recentProgress = await prisma.audiobookProgress.findMany({
where: {
userId: req.user!.id,
isFinished: false,
currentTime: {
gt: 0,
},
},
orderBy: {
lastPlayedAt: "desc",
},
take: 10,
});
// Transform the cover URLs to use the audiobook__ prefix for the proxy
const transformed = recentProgress.map((progress: any) => {
const coverUrl =
progress.coverUrl && !progress.coverUrl.startsWith("http")
? `audiobook__${progress.coverUrl}`
: progress.coverUrl;
return {
...progress,
coverUrl,
};
});
res.json(transformed);
} catch (error: any) {
console.error("Error fetching continue listening:", error);
res.status(500).json({
error: "Failed to fetch continue listening",
message: error.message,
});
}
}
);
/**
* POST /audiobooks/sync
* Manually trigger audiobook sync from Audiobookshelf
* Fetches all audiobooks and caches metadata + cover images locally
*/
router.post("/sync", requireAuthOrToken, apiLimiter, async (req, res) => {
try {
const { getSystemSettings } = await import("../utils/systemSettings");
const { notificationService } = await import("../services/notificationService");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res
.status(400)
.json({ error: "Audiobookshelf not enabled" });
}
console.log("[Audiobooks] Starting manual audiobook sync...");
const result = await audiobookCacheService.syncAll();
// Check how many have series after sync
const seriesCount = await prisma.audiobook.count({
where: { series: { not: null } },
});
console.log(
`[Audiobooks] Sync complete. Books with series: ${seriesCount}`
);
// Send notification to user
if (req.user?.id) {
await notificationService.notifySystem(
req.user.id,
"Audiobook Sync Complete",
`Synced ${result.synced || 0} audiobooks (${seriesCount} with series)`
);
}
res.json({
success: true,
result,
});
} catch (error: any) {
console.error("Audiobook sync failed:", error);
res.status(500).json({
error: "Sync failed",
message: error.message,
});
}
});
/**
* GET /audiobooks/debug-series
* Debug endpoint to see raw series data from Audiobookshelf
*/
// Debug endpoint for series data
router.get("/debug-series", requireAuthOrToken, async (req, res) => {
console.log("[Audiobooks] Debug series endpoint called");
try {
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res
.status(400)
.json({ error: "Audiobookshelf not enabled" });
}
// Get raw data from Audiobookshelf
const rawBooks = await audiobookshelfService.getAllAudiobooks();
console.log(
`[Audiobooks] Got ${rawBooks.length} books from Audiobookshelf`
);
// Find books with series data
const booksWithSeries = rawBooks.filter((book: any) => {
const metadata = book.media?.metadata || book;
return metadata.series || metadata.seriesName;
});
console.log(
`[Audiobooks] Books with series data: ${booksWithSeries.length}`
);
// Extract series info from all books (first 20)
const allSeriesInfo = rawBooks.slice(0, 20).map((book: any) => {
const metadata = book.media?.metadata || book;
return {
title: metadata.title || book.title,
rawSeries: metadata.series,
seriesName: metadata.seriesName,
seriesSequence: metadata.seriesSequence,
// Also check if there's series in the top-level book object
bookSeries: book.series,
};
});
// Get a full sample of one book with series (if any)
let fullSample = null;
if (booksWithSeries.length > 0) {
const sampleBook = booksWithSeries[0];
fullSample = {
id: sampleBook.id,
media: sampleBook.media,
};
}
res.json({
totalBooks: rawBooks.length,
booksWithSeriesCount: booksWithSeries.length,
sampleSeriesData: allSeriesInfo,
fullSampleWithSeries: fullSample,
});
} catch (error: any) {
console.error("[Audiobooks] Debug series error:", error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /audiobooks/search
* Search audiobooks
*/
router.get("/search", requireAuthOrToken, apiLimiter, async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json([]);
}
const { q } = req.query;
if (!q || typeof q !== "string") {
return res.status(400).json({ error: "Query parameter required" });
}
const results = await audiobookshelfService.searchAudiobooks(q);
res.json(results);
} catch (error: any) {
console.error("Error searching audiobooks:", error);
res.status(500).json({
error: "Failed to search audiobooks",
message: error.message,
});
}
});
/**
* GET /audiobooks
* Get all audiobooks from cached database (instant, no API calls)
*/
router.get("/", requireAuthOrToken, apiLimiter, async (req, res) => {
console.log("[Audiobooks] GET / - fetching audiobooks list");
try {
// Check if Audiobookshelf is enabled first
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({
configured: false,
enabled: false,
audiobooks: [],
});
}
// Read from cached database instead of hitting Audiobookshelf API
const audiobooks = await prisma.audiobook.findMany({
orderBy: { title: "asc" },
});
const audiobookIds = audiobooks.map((book) => book.id);
const progressEntries =
audiobookIds.length > 0
? await prisma.audiobookProgress.findMany({
where: {
userId: req.user!.id,
audiobookshelfId: { in: audiobookIds },
},
})
: [];
const progressMap = new Map(
progressEntries.map((entry) => [entry.audiobookshelfId, entry])
);
// Get user's progress for each audiobook
const audiobooksWithProgress = audiobooks.map((book) => {
const progress = progressMap.get(book.id);
// Cover URL: if we have localCoverPath or coverUrl from Audiobookshelf, serve from our endpoint
// The /audiobooks/:id/cover endpoint will find the file on disk even if localCoverPath isn't set
const hasCover = book.localCoverPath || book.coverUrl;
return {
id: book.id,
title: book.title,
author: book.author || "Unknown Author",
narrator: book.narrator,
description: book.description,
coverUrl: hasCover
? `/audiobooks/${book.id}/cover` // Serve from local disk
: null,
duration: book.duration || 0,
libraryId: book.libraryId,
series: book.series
? {
name: book.series,
sequence: book.seriesSequence || "1",
}
: null,
genres: book.genres || [],
progress: progress
? {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime / progress.duration) *
100
: 0,
isFinished: progress.isFinished,
lastPlayedAt: progress.lastPlayedAt,
}
: null,
};
});
res.json(audiobooksWithProgress);
} catch (error: any) {
console.error("Error fetching audiobooks:", error);
res.status(500).json({
error: "Failed to fetch audiobooks",
message: error.message,
});
}
});
/**
* GET /audiobooks/series/:seriesName
* Get all books in a series (from cached database)
*/
router.get(
"/series/:seriesName",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json([]);
}
const { seriesName } = req.params;
const decodedSeriesName = decodeURIComponent(seriesName);
// Read from cached database
const audiobooks = await prisma.audiobook.findMany({
where: {
series: decodedSeriesName,
},
orderBy: {
seriesSequence: "asc",
},
});
const seriesIds = audiobooks.map((book) => book.id);
const seriesProgressEntries =
seriesIds.length > 0
? await prisma.audiobookProgress.findMany({
where: {
userId: req.user!.id,
audiobookshelfId: { in: seriesIds },
},
})
: [];
const seriesProgressMap = new Map(
seriesProgressEntries.map((entry) => [
entry.audiobookshelfId,
entry,
])
);
const seriesBooks = audiobooks.map((book) => {
const progress = seriesProgressMap.get(book.id);
return {
id: book.id,
title: book.title,
author: book.author || "Unknown Author",
narrator: book.narrator,
description: book.description,
coverUrl:
book.localCoverPath || book.coverUrl
? `/audiobooks/${book.id}/cover`
: null,
duration: book.duration || 0,
libraryId: book.libraryId,
series: book.series
? {
name: book.series,
sequence: book.seriesSequence || "1",
}
: null,
genres: book.genres || [],
progress: progress
? {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime /
progress.duration) *
100
: 0,
isFinished: progress.isFinished,
lastPlayedAt: progress.lastPlayedAt,
}
: null,
};
});
res.json(seriesBooks);
} catch (error: any) {
console.error("Error fetching series:", error);
res.status(500).json({
error: "Failed to fetch series",
message: error.message,
});
}
}
);
/**
* OPTIONS /audiobooks/:id/cover
* Handle CORS preflight request for cover images
*/
router.options("/:id/cover", (req, res) => {
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Max-Age", "86400"); // 24 hours
res.status(204).end();
});
/**
* GET /audiobooks/:id/cover
* Serve cached cover image from local disk (instant, no proxying)
* NO RATE LIMITING - These are static files served from disk with aggressive caching
*/
router.get("/:id/cover", async (req, res) => {
try {
const { id } = req.params;
const fs = await import("fs");
const path = await import("path");
const { config } = await import("../config");
const audiobook = await prisma.audiobook.findUnique({
where: { id },
select: { localCoverPath: true },
});
let coverPath = audiobook?.localCoverPath;
// Fallback: check if cover exists on disk even if DB path is empty
if (!coverPath) {
const fallbackPath = path.join(
config.music.musicPath,
"cover-cache",
"audiobooks",
`${id}.jpg`
);
if (fs.existsSync(fallbackPath)) {
coverPath = fallbackPath;
// Update database with the correct path
await prisma.audiobook
.update({
where: { id },
data: { localCoverPath: fallbackPath },
})
.catch(() => {}); // Ignore errors if audiobook doesn't exist
}
}
if (!coverPath) {
return res.status(404).json({ error: "Cover not found" });
}
// Verify file exists before sending
if (!fs.existsSync(coverPath)) {
return res.status(404).json({ error: "Cover file missing" });
}
// Serve image from local disk with aggressive caching and CORS headers
// Use specific origin instead of * to support credentials mode
const origin = req.headers.origin || "http://localhost:3030";
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.sendFile(coverPath);
} catch (error: any) {
console.error("Error serving cover:", error);
res.status(500).json({
error: "Failed to serve cover",
message: error.message,
});
}
});
/**
* GET /audiobooks/:id
* Get a specific audiobook with full details (from cache, fallback to API)
*/
router.get("/:id", requireAuthOrToken, apiLimiter, async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({ configured: false, enabled: false });
}
const { id } = req.params;
// Try to get from cache first
let audiobook = await prisma.audiobook.findUnique({
where: { id },
});
// If not cached or stale, fetch from API and cache it
if (
!audiobook ||
audiobook.lastSyncedAt <
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) {
console.log(
`[AUDIOBOOK] Audiobook ${id} not cached or stale, fetching...`
);
audiobook = await audiobookCacheService.getAudiobook(id);
}
// Get chapters and audio files from API (these change less frequently)
let absBook;
try {
absBook = await audiobookshelfService.getAudiobook(id);
} catch (apiError: any) {
console.warn(
` Failed to fetch live data from Audiobookshelf for ${id}, using cached data only:`,
apiError.message
);
// Continue with cached data only if API call fails
absBook = { media: { chapters: [], audioFiles: [] } };
}
// Get user's progress
const progress = await prisma.audiobookProgress.findUnique({
where: {
userId_audiobookshelfId: {
userId: req.user!.id,
audiobookshelfId: id,
},
},
});
const response = {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author || "Unknown Author",
narrator: audiobook.narrator,
description: audiobook.description,
coverUrl:
audiobook.localCoverPath || audiobook.coverUrl
? `/audiobooks/${audiobook.id}/cover`
: null,
duration: audiobook.duration || 0,
chapters: absBook.media?.chapters || [],
audioFiles: absBook.media?.audioFiles || [],
libraryId: audiobook.libraryId,
progress: progress
? {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime / progress.duration) * 100
: 0,
isFinished: progress.isFinished,
lastPlayedAt: progress.lastPlayedAt,
}
: null,
};
res.json(response);
} catch (error: any) {
console.error("Error fetching audiobook__", error);
res.status(500).json({
error: "Failed to fetch audiobook",
message: error.message,
});
}
});
/**
* GET /audiobooks/:id/stream
* Proxy the audiobook stream with authentication
*/
router.get("/:id/stream", requireAuthOrToken, async (req, res) => {
try {
console.log(
`[Audiobook Stream] Request for audiobook: ${req.params.id}`
);
console.log(`[Audiobook Stream] User: ${req.user?.id || "unknown"}`);
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
console.log("[Audiobook Stream] Audiobookshelf not enabled");
return res
.status(503)
.json({ error: "Audiobookshelf is not configured" });
}
const { id } = req.params;
const rangeHeader = req.headers.range as string | undefined;
console.log(
`[Audiobook Stream] Fetching stream for ${id}, range: ${
rangeHeader || "none"
}`
);
const { stream, headers, status } =
await audiobookshelfService.streamAudiobook(id, rangeHeader);
console.log(
`[Audiobook Stream] Got stream, status: ${status}, content-type: ${headers["content-type"]}`
);
const responseStatus = status || (rangeHeader ? 206 : 200);
res.status(responseStatus);
// Set content type - ensure it's audio
const contentType = headers["content-type"] || "audio/mpeg";
res.setHeader("Content-Type", contentType);
// Set other headers
if (headers["content-length"]) {
res.setHeader("Content-Length", headers["content-length"]);
}
if (headers["accept-ranges"]) {
res.setHeader("Accept-Ranges", headers["accept-ranges"]);
} else {
res.setHeader("Accept-Ranges", "bytes");
}
if (headers["content-range"]) {
res.setHeader("Content-Range", headers["content-range"]);
}
res.setHeader("Cache-Control", "public, max-age=0");
// Clean up upstream stream when client disconnects (e.g., skips track, closes browser)
res.on("close", () => {
if (!stream.destroyed) {
stream.destroy();
}
});
stream.pipe(res);
stream.on("error", (error: any) => {
console.error("[Audiobook Stream] Stream error:", error);
if (!res.headersSent) {
res.status(500).json({
error: "Failed to stream audiobook",
message: error.message,
});
} else {
res.end();
}
});
} catch (error: any) {
console.error("[Audiobook Stream] Error:", error.message);
res.status(500).json({
error: "Failed to stream audiobook",
message: error.message,
});
}
});
/**
* POST /audiobooks/:id/progress
* Update playback progress for an audiobook
*/
router.post(
"/:id/progress",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({
success: false,
message: "Audiobookshelf is not configured",
});
}
const { id } = req.params;
const {
currentTime: rawCurrentTime,
duration: rawDuration,
isFinished,
} = req.body;
const currentTime =
typeof rawCurrentTime === "number" &&
Number.isFinite(rawCurrentTime)
? Math.max(0, rawCurrentTime)
: 0;
const durationValue =
typeof rawDuration === "number" && Number.isFinite(rawDuration)
? Math.max(rawDuration, 0)
: 0;
console.log(`\n [AUDIOBOOK PROGRESS] Received update:`);
console.log(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`);
console.log(
` Current Time: ${currentTime}s (${Math.floor(
currentTime / 60
)} mins)`
);
console.log(
` Duration: ${durationValue}s (${Math.floor(
durationValue / 60
)} mins)`
);
if (durationValue > 0) {
console.log(
` Progress: ${(
(currentTime / durationValue) *
100
).toFixed(1)}%`
);
} else {
console.log(" Progress: duration unknown");
}
console.log(` Finished: ${!!isFinished}`);
// Pull cached metadata to avoid hitting Audiobookshelf for every update
const [cachedAudiobook, existingProgress] = await Promise.all([
prisma.audiobook.findUnique({
where: { id },
select: {
title: true,
author: true,
coverUrl: true,
duration: true,
libraryId: true,
localCoverPath: true,
},
}),
prisma.audiobookProgress.findUnique({
where: {
userId_audiobookshelfId: {
userId: req.user!.id,
audiobookshelfId: id,
},
},
}),
]);
const fallbackDuration =
durationValue ||
cachedAudiobook?.duration ||
existingProgress?.duration ||
0;
const metadataTitle =
cachedAudiobook?.title ||
existingProgress?.title ||
"Unknown Title";
const metadataAuthor =
cachedAudiobook?.author ||
existingProgress?.author ||
"Unknown Author";
const metadataCover =
cachedAudiobook?.coverUrl || existingProgress?.coverUrl || null;
// Update progress in our database
const progress = await prisma.audiobookProgress.upsert({
where: {
userId_audiobookshelfId: {
userId: req.user!.id,
audiobookshelfId: id,
},
},
create: {
userId: req.user!.id,
audiobookshelfId: id,
title: metadataTitle,
author: metadataAuthor,
coverUrl: metadataCover,
currentTime,
duration: fallbackDuration,
isFinished: !!isFinished,
lastPlayedAt: new Date(),
},
update: {
title: metadataTitle,
author: metadataAuthor,
coverUrl: metadataCover,
currentTime,
duration: fallbackDuration,
isFinished: !!isFinished,
lastPlayedAt: new Date(),
},
});
console.log(` Progress saved to database`);
// Also update progress in Audiobookshelf
try {
await audiobookshelfService.updateProgress(
id,
currentTime,
fallbackDuration,
isFinished
);
console.log(` Progress synced to Audiobookshelf`);
} catch (error) {
console.error(
"Failed to sync progress to Audiobookshelf:",
error
);
// Continue anyway - local progress is saved
}
res.json({
success: true,
progress: {
currentTime: progress.currentTime,
progress:
progress.duration > 0
? (progress.currentTime / progress.duration) * 100
: 0,
isFinished: progress.isFinished,
},
});
} catch (error: any) {
console.error("Error updating progress:", error);
res.status(500).json({
error: "Failed to update progress",
message: error.message,
});
}
}
);
/**
* DELETE /audiobooks/:id/progress
* Remove/reset progress for an audiobook
*/
router.delete(
"/:id/progress",
requireAuthOrToken,
apiLimiter,
async (req, res) => {
try {
// Check if Audiobookshelf is enabled
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfEnabled) {
return res.status(200).json({
success: false,
message: "Audiobookshelf is not configured",
});
}
const { id } = req.params;
console.log(`\n[AUDIOBOOK PROGRESS] Removing progress:`);
console.log(` User: ${req.user!.username}`);
console.log(` Audiobook ID: ${id}`);
// Delete progress from our database
await prisma.audiobookProgress.deleteMany({
where: {
userId: req.user!.id,
audiobookshelfId: id,
},
});
console.log(` Progress removed from database`);
// Also remove progress from Audiobookshelf
try {
await audiobookshelfService.updateProgress(id, 0, 0, false);
console.log(` Progress reset in Audiobookshelf`);
} catch (error) {
console.error(
"Failed to reset progress in Audiobookshelf:",
error
);
// Continue anyway - local progress is deleted
}
res.json({
success: true,
message: "Progress removed",
});
} catch (error: any) {
console.error("Error removing progress:", error);
res.status(500).json({
error: "Failed to remove progress",
message: error.message,
});
}
}
);
export default router;
+532
View File
@@ -0,0 +1,532 @@
import { Router } from "express";
import bcrypt from "bcrypt";
import { prisma } from "../utils/db";
import { z } from "zod";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import crypto from "crypto";
import { requireAuth, requireAdmin, generateToken } from "../middleware/auth";
import { encrypt, decrypt } from "../utils/encryption";
const router = Router();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
// Use shared encryption module for 2FA secrets
const encrypt2FASecret = encrypt;
const decrypt2FASecret = decrypt;
/**
* @openapi
* /auth/login:
* post:
* summary: Login with username and password
* tags: [Authentication]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - username
* - password
* properties:
* username:
* type: string
* password:
* type: string
* format: password
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// POST /auth/login
router.post("/login", async (req, res) => {
try {
const { username, password } = loginSchema.parse(req.body);
const { token } = req.body; // 2FA token if provided
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Check if 2FA is enabled
if (user.twoFactorEnabled && user.twoFactorSecret) {
if (!token) {
return res.status(200).json({
requires2FA: true,
message: "2FA token required",
userId: user.id, // Send userId for next 2FA request
});
}
// Check if it's a recovery code
const isRecoveryCode = /^[A-F0-9]{8}$/i.test(token);
if (isRecoveryCode && user.twoFactorRecoveryCodes) {
const encryptedCodes = user.twoFactorRecoveryCodes;
const decryptedCodes = decrypt2FASecret(encryptedCodes);
const hashedCodes = decryptedCodes.split(",");
const providedHash = crypto
.createHash("sha256")
.update(token.toUpperCase())
.digest("hex");
const codeIndex = hashedCodes.indexOf(providedHash);
if (codeIndex === -1) {
return res.status(401).json({ error: "Invalid recovery code" });
}
hashedCodes.splice(codeIndex, 1);
await prisma.user.update({
where: { id: user.id },
data: { twoFactorRecoveryCodes: encrypt2FASecret(hashedCodes.join(",")) },
});
} else {
// Verify TOTP token
const secret = decrypt2FASecret(user.twoFactorSecret);
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid 2FA token" });
}
}
}
// Generate JWT token
const jwtToken = generateToken({
id: user.id,
username: user.username,
role: user.role,
});
res.json({
token: jwtToken,
user: {
id: user.id,
username: user.username,
role: user.role,
},
});
} catch (err) {
if (err instanceof z.ZodError) {
return res.status(400).json({ error: "Invalid request", details: err.errors });
}
console.error("Login error:", err);
res.status(500).json({ error: "Internal error" });
}
});
// POST /auth/logout - JWT is stateless, logout is handled client-side
router.post("/logout", (req, res) => {
// With JWT, logout is handled by client removing the token
// No server-side session to destroy
res.json({ message: "Logged out" });
});
/**
* @openapi
* /auth/me:
* get:
* summary: Get current authenticated user
* tags: [Authentication]
* security:
* - sessionAuth: []
* responses:
* 200:
* description: Current user information
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// GET /auth/me
router.get("/me", requireAuth, async (req, res) => {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: {
id: true,
username: true,
role: true,
onboardingComplete: true,
enrichmentSettings: true,
createdAt: true,
},
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json(user);
});
// POST /auth/change-password
router.post("/change-password", requireAuth, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res
.status(400)
.json({ error: "Current and new password are required" });
}
if (newPassword.length < 6) {
return res
.status(400)
.json({ error: "New password must be at least 6 characters" });
}
// Verify current password
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
return res
.status(401)
.json({ error: "Current password is incorrect" });
}
// Update password
const newPasswordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({
where: { id: req.user!.id },
data: { passwordHash: newPasswordHash },
});
res.json({ message: "Password changed successfully" });
} catch (error) {
console.error("Change password error:", error);
res.status(500).json({ error: "Failed to change password" });
}
});
// GET /auth/users (Admin only)
router.get("/users", requireAuth, requireAdmin, async (req, res) => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
role: true,
onboardingComplete: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
res.json(users);
} catch (error) {
console.error("Get users error:", error);
res.status(500).json({ error: "Failed to get users" });
}
});
// POST /auth/create-user (Admin only)
router.post("/create-user", requireAuth, requireAdmin, async (req, res) => {
try {
const { username, password, role } = req.body;
if (!username || !password) {
return res
.status(400)
.json({ error: "Username and password are required" });
}
if (password.length < 6) {
return res
.status(400)
.json({ error: "Password must be at least 6 characters" });
}
if (role && !["user", "admin"].includes(role)) {
return res.status(400).json({ error: "Invalid role" });
}
// Check if username exists
const existing = await prisma.user.findUnique({
where: { username },
});
if (existing) {
return res.status(400).json({ error: "Username already taken" });
}
// Create user
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
role: role || "user",
onboardingComplete: true, // Skip onboarding for created users
},
});
// Create default user settings
await prisma.userSettings.create({
data: {
userId: user.id,
playbackQuality: "original",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 10240,
},
});
res.json({
id: user.id,
username: user.username,
role: user.role,
createdAt: user.createdAt,
});
} catch (error) {
console.error("Create user error:", error);
res.status(500).json({ error: "Failed to create user" });
}
});
// DELETE /auth/users/:id (Admin only)
router.delete("/users/:id", requireAuth, requireAdmin, async (req, res) => {
try {
const { id } = req.params;
// Prevent deleting yourself
if (id === req.user!.id) {
return res
.status(400)
.json({ error: "Cannot delete your own account" });
}
// Delete user (cascade will handle related data)
await prisma.user.delete({
where: { id },
});
res.json({ message: "User deleted successfully" });
} catch (error: any) {
console.error("Delete user error:", error);
if (error.code === "P2025") {
return res.status(404).json({ error: "User not found" });
}
res.status(500).json({ error: "Failed to delete user" });
}
});
// POST /auth/2fa/setup - Generate 2FA secret and QR code
router.post("/2fa/setup", requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { username: true, twoFactorEnabled: true },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({ error: "2FA is already enabled" });
}
// Generate secret
const secret = speakeasy.generateSecret({
name: `Lidify (${user.username})`,
issuer: "Lidify",
});
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!);
res.json({
secret: secret.base32,
qrCode: qrCodeDataUrl,
});
} catch (error) {
console.error("2FA setup error:", error);
res.status(500).json({ error: "Failed to setup 2FA" });
}
});
// POST /auth/2fa/enable - Verify token and enable 2FA
router.post("/2fa/enable", requireAuth, async (req, res) => {
try {
const { secret, token } = req.body;
if (!secret || !token) {
return res
.status(400)
.json({ error: "Secret and token are required" });
}
// Verify the token with the secret
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 2,
});
if (!verified) {
return res
.status(401)
.json({ error: "Invalid token. Please try again." });
}
// Generate 10 recovery codes
const recoveryCodes: string[] = [];
const hashedRecoveryCodes: string[] = [];
for (let i = 0; i < 10; i++) {
// Generate 8-character alphanumeric code
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
recoveryCodes.push(code);
// Hash the code before storing
hashedRecoveryCodes.push(
crypto.createHash("sha256").update(code).digest("hex")
);
}
// Encrypt the hashed codes for storage
const encryptedRecoveryCodes = encrypt2FASecret(
hashedRecoveryCodes.join(",")
);
// Encrypt and save the secret
const encryptedSecret = encrypt2FASecret(secret);
await prisma.user.update({
where: { id: req.user!.id },
data: {
twoFactorEnabled: true,
twoFactorSecret: encryptedSecret,
twoFactorRecoveryCodes: encryptedRecoveryCodes,
},
});
// Return the plain recovery codes to the user (only time they'll see them)
res.json({
message: "2FA enabled successfully",
recoveryCodes: recoveryCodes,
});
} catch (error) {
console.error("2FA enable error:", error);
res.status(500).json({ error: "Failed to enable 2FA" });
}
});
// POST /auth/2fa/disable - Disable 2FA
router.post("/2fa/disable", requireAuth, async (req, res) => {
try {
const { password, token } = req.body;
if (!password || !token) {
return res
.status(400)
.json({ error: "Password and current 2FA token are required" });
}
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: "Invalid password" });
}
// Verify 2FA token
if (user.twoFactorSecret) {
const secret = decrypt2FASecret(user.twoFactorSecret);
const verified = speakeasy.totp.verify({
secret,
encoding: "base32",
token,
window: 2,
});
if (!verified) {
return res.status(401).json({ error: "Invalid 2FA token" });
}
}
// Disable 2FA
await prisma.user.update({
where: { id: req.user!.id },
data: {
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorRecoveryCodes: null,
},
});
res.json({ message: "2FA disabled successfully" });
} catch (error) {
console.error("2FA disable error:", error);
res.status(500).json({ error: "Failed to disable 2FA" });
}
});
// GET /auth/2fa/status - Check if 2FA is enabled
router.get("/2fa/status", requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user!.id },
select: { twoFactorEnabled: true },
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({ enabled: user.twoFactorEnabled });
} catch (error) {
console.error("2FA status error:", error);
res.status(500).json({ error: "Failed to get 2FA status" });
}
});
export default router;
+377
View File
@@ -0,0 +1,377 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { spotifyService } from "../services/spotify";
import { deezerService, DeezerPlaylistPreview, DeezerRadioStation } from "../services/deezer";
const router = Router();
// All routes require authentication
router.use(requireAuthOrToken);
/**
* Unified playlist preview type
*/
interface PlaylistPreview {
id: string;
source: "deezer" | "spotify";
type: "playlist" | "radio";
title: string;
description: string | null;
creator: string;
imageUrl: string | null;
trackCount: number;
url: string;
}
/**
* Convert Deezer playlist to unified format
*/
function deezerPlaylistToUnified(playlist: DeezerPlaylistPreview): PlaylistPreview {
return {
id: playlist.id,
source: "deezer",
type: "playlist",
title: playlist.title,
description: playlist.description,
creator: playlist.creator,
imageUrl: playlist.imageUrl,
trackCount: playlist.trackCount,
url: `https://www.deezer.com/playlist/${playlist.id}`,
};
}
/**
* Convert Deezer radio to unified format
*/
function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview {
return {
id: radio.id,
source: "deezer",
type: "radio",
title: radio.title,
description: radio.description,
creator: "Deezer",
imageUrl: radio.imageUrl,
trackCount: 0, // Radio tracks are dynamic
url: `https://www.deezer.com/radio-${radio.id}`,
};
}
// ============================================
// Playlist Endpoints
// ============================================
/**
* GET /api/browse/playlists/featured
* Get featured/chart playlists from Deezer
*/
router.get("/playlists/featured", async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
console.log(`[Browse] Fetching featured playlists (limit: ${limit})...`);
const playlists = await deezerService.getFeaturedPlaylists(limit);
console.log(`[Browse] Got ${playlists.length} Deezer playlists`);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
total: playlists.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse featured playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlists" });
}
});
/**
* GET /api/browse/playlists/search
* Search for playlists on Deezer
*/
router.get("/playlists/search", async (req, res) => {
try {
const query = req.query.q as string;
if (!query || query.length < 2) {
return res.status(400).json({ error: "Search query must be at least 2 characters" });
}
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
console.log(`[Browse] Searching playlists for "${query}"...`);
const playlists = await deezerService.searchPlaylists(query, limit);
console.log(`[Browse] Search "${query}": ${playlists.length} results`);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
total: playlists.length,
query,
source: "deezer",
});
} catch (error: any) {
console.error("Browse search playlists error:", error);
res.status(500).json({ error: error.message || "Failed to search playlists" });
}
});
/**
* GET /api/browse/playlists/:id
* Get full details of a Deezer playlist
*/
router.get("/playlists/:id", async (req, res) => {
try {
const { id } = req.params;
const playlist = await deezerService.getPlaylist(id);
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
res.json({
...playlist,
source: "deezer",
url: `https://www.deezer.com/playlist/${id}`,
});
} catch (error: any) {
console.error("Playlist fetch error:", error);
res.status(500).json({ error: error.message || "Failed to fetch playlist" });
}
});
// ============================================
// Radio Endpoints
// ============================================
/**
* GET /api/browse/radios
* Get all radio stations (mood/theme based mixes)
*/
router.get("/radios", async (req, res) => {
try {
console.log("[Browse] Fetching radio stations...");
const radios = await deezerService.getRadioStations();
res.json({
radios: radios.map(deezerRadioToUnified),
total: radios.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse radios error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" });
}
});
/**
* GET /api/browse/radios/by-genre
* Get radio stations organized by genre
*/
router.get("/radios/by-genre", async (req, res) => {
try {
console.log("[Browse] Fetching radios by genre...");
const genresWithRadios = await deezerService.getRadiosByGenre();
// Transform to include unified format
const result = genresWithRadios.map(genre => ({
id: genre.id,
name: genre.name,
radios: genre.radios.map(deezerRadioToUnified),
}));
res.json({
genres: result,
total: result.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse radios by genre error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radios" });
}
});
/**
* GET /api/browse/radios/:id
* Get tracks from a radio station (as playlist format for import)
*/
router.get("/radios/:id", async (req, res) => {
try {
const { id } = req.params;
console.log(`[Browse] Fetching radio ${id} tracks...`);
const radioPlaylist = await deezerService.getRadioTracks(id);
if (!radioPlaylist) {
return res.status(404).json({ error: "Radio station not found" });
}
res.json({
...radioPlaylist,
source: "deezer",
type: "radio",
});
} catch (error: any) {
console.error("Radio tracks error:", error);
res.status(500).json({ error: error.message || "Failed to fetch radio tracks" });
}
});
// ============================================
// Genre Endpoints
// ============================================
/**
* GET /api/browse/genres
* Get all available genres
*/
router.get("/genres", async (req, res) => {
try {
console.log("[Browse] Fetching genres...");
const genres = await deezerService.getGenres();
res.json({
genres,
total: genres.length,
source: "deezer",
});
} catch (error: any) {
console.error("Browse genres error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genres" });
}
});
/**
* GET /api/browse/genres/:id
* Get content for a specific genre (playlists + radios)
*/
router.get("/genres/:id", async (req, res) => {
try {
const genreId = parseInt(req.params.id);
if (isNaN(genreId)) {
return res.status(400).json({ error: "Invalid genre ID" });
}
console.log(`[Browse] Fetching content for genre ${genreId}...`);
const content = await deezerService.getEditorialContent(genreId);
res.json({
genreId,
playlists: content.playlists.map(deezerPlaylistToUnified),
radios: content.radios.map(deezerRadioToUnified),
source: "deezer",
});
} catch (error: any) {
console.error("Genre content error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre content" });
}
});
/**
* GET /api/browse/genres/:id/playlists
* Get playlists for a specific genre (by name search)
*/
router.get("/genres/:id/playlists", async (req, res) => {
try {
const genreId = parseInt(req.params.id);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
// Get genre name first
const genres = await deezerService.getGenres();
const genre = genres.find(g => g.id === genreId);
if (!genre) {
return res.status(404).json({ error: "Genre not found" });
}
const playlists = await deezerService.getGenrePlaylists(genre.name, limit);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
total: playlists.length,
genre: genre.name,
source: "deezer",
});
} catch (error: any) {
console.error("Genre playlists error:", error);
res.status(500).json({ error: error.message || "Failed to fetch genre playlists" });
}
});
// ============================================
// URL Parsing (supports both Spotify & Deezer)
// ============================================
/**
* POST /api/browse/playlists/parse
* Parse a Spotify or Deezer URL and return playlist info
* This is the main entry point for URL-based imports
*/
router.post("/playlists/parse", async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: "URL is required" });
}
// Try Deezer first (our primary source)
const deezerParsed = deezerService.parseUrl(url);
if (deezerParsed && deezerParsed.type === "playlist") {
return res.json({
source: "deezer",
type: "playlist",
id: deezerParsed.id,
url: `https://www.deezer.com/playlist/${deezerParsed.id}`,
});
}
// Try Spotify (still supported for URL imports)
const spotifyParsed = spotifyService.parseUrl(url);
if (spotifyParsed && spotifyParsed.type === "playlist") {
return res.json({
source: "spotify",
type: "playlist",
id: spotifyParsed.id,
url: `https://open.spotify.com/playlist/${spotifyParsed.id}`,
});
}
return res.status(400).json({
error: "Invalid or unsupported URL. Please provide a Spotify or Deezer playlist URL."
});
} catch (error: any) {
console.error("Parse URL error:", error);
res.status(500).json({ error: error.message || "Failed to parse URL" });
}
});
// ============================================
// Combined Browse Endpoint (for frontend convenience)
// ============================================
/**
* GET /api/browse/all
* Get a combined view of featured content (playlists, genres)
* Note: Radio stations are now internal (library-based), not from Deezer
*/
router.get("/all", async (req, res) => {
try {
console.log("[Browse] Fetching browse content (playlists + genres)...");
// Only fetch playlists and genres - radios are now internal library-based
const [playlists, genres] = await Promise.all([
deezerService.getFeaturedPlaylists(200),
deezerService.getGenres(),
]);
res.json({
playlists: playlists.map(deezerPlaylistToUnified),
radios: [], // Radio stations are now internal (use /api/library/radio)
genres,
radiosByGenre: [], // Deprecated - use internal radios
source: "deezer",
});
} catch (error: any) {
console.error("Browse all error:", error);
res.status(500).json({ error: error.message || "Failed to fetch browse content" });
}
});
export default router;
+232
View File
@@ -0,0 +1,232 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import crypto from "crypto";
const router = Router();
// Generate a random 6-character alphanumeric code
function generateLinkCode(): string {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // Exclude similar looking chars
let code = "";
for (let i = 0; i < 6; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
return code;
}
// Generate API key
function generateApiKey(): string {
return crypto.randomBytes(32).toString("hex");
}
// POST /device-link/generate - Generate a new device link code (requires auth)
router.post("/generate", requireAuthOrToken, async (req, res) => {
try {
const userId = req.user!.id;
// Delete any existing unused codes for this user
await prisma.deviceLinkCode.deleteMany({
where: {
userId,
usedAt: null,
},
});
// Generate a unique code
let code: string;
let attempts = 0;
do {
code = generateLinkCode();
attempts++;
if (attempts > 10) {
return res.status(500).json({ error: "Failed to generate unique code" });
}
} while (
await prisma.deviceLinkCode.findUnique({
where: { code },
})
);
// Create the code with 5-minute expiry
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
const linkCode = await prisma.deviceLinkCode.create({
data: {
code,
userId,
expiresAt,
},
});
res.json({
code: linkCode.code,
expiresAt: linkCode.expiresAt,
expiresIn: 300, // 5 minutes in seconds
});
} catch (error) {
console.error("Generate device link code error:", error);
res.status(500).json({ error: "Failed to generate device link code" });
}
});
// POST /device-link/verify - Verify a code and get API key (no auth required)
router.post("/verify", async (req, res) => {
try {
const { code, deviceName } = req.body;
if (!code || typeof code !== "string") {
return res.status(400).json({ error: "Code is required" });
}
// Find the code
const linkCode = await prisma.deviceLinkCode.findUnique({
where: { code: code.toUpperCase() },
include: { user: true },
});
if (!linkCode) {
return res.status(404).json({ error: "Invalid code" });
}
if (linkCode.usedAt) {
return res.status(400).json({ error: "Code already used" });
}
if (new Date() > linkCode.expiresAt) {
return res.status(400).json({ error: "Code expired" });
}
// Generate API key for this device
const apiKey = generateApiKey();
const createdApiKey = await prisma.apiKey.create({
data: {
userId: linkCode.userId,
key: apiKey,
name: deviceName || "Mobile Device",
},
});
// Mark the link code as used
await prisma.deviceLinkCode.update({
where: { id: linkCode.id },
data: {
usedAt: new Date(),
deviceName: deviceName || "Mobile Device",
apiKeyId: createdApiKey.id,
},
});
res.json({
success: true,
apiKey,
userId: linkCode.userId,
username: linkCode.user.username,
});
} catch (error) {
console.error("Verify device link code error:", error);
res.status(500).json({ error: "Failed to verify device link code" });
}
});
// GET /device-link/status/:code - Poll for code usage status (no auth required)
router.get("/status/:code", async (req, res) => {
try {
const { code } = req.params;
const linkCode = await prisma.deviceLinkCode.findUnique({
where: { code: code.toUpperCase() },
});
if (!linkCode) {
return res.status(404).json({ error: "Invalid code" });
}
if (new Date() > linkCode.expiresAt && !linkCode.usedAt) {
return res.json({
status: "expired",
expiresAt: linkCode.expiresAt,
});
}
if (linkCode.usedAt) {
return res.json({
status: "used",
usedAt: linkCode.usedAt,
deviceName: linkCode.deviceName,
});
}
res.json({
status: "pending",
expiresAt: linkCode.expiresAt,
});
} catch (error) {
console.error("Check device link status error:", error);
res.status(500).json({ error: "Failed to check status" });
}
});
// GET /device-link/devices - List linked devices (requires auth)
router.get("/devices", requireAuthOrToken, async (req, res) => {
try {
const userId = req.user!.id;
const apiKeys = await prisma.apiKey.findMany({
where: { userId },
orderBy: { lastUsed: "desc" },
select: {
id: true,
name: true,
lastUsed: true,
createdAt: true,
},
});
res.json(apiKeys);
} catch (error) {
console.error("Get devices error:", error);
res.status(500).json({ error: "Failed to get devices" });
}
});
// DELETE /device-link/devices/:id - Revoke a device (requires auth)
router.delete("/devices/:id", requireAuthOrToken, async (req, res) => {
try {
const userId = req.user!.id;
const { id } = req.params;
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId },
});
if (!apiKey) {
return res.status(404).json({ error: "Device not found" });
}
await prisma.apiKey.delete({
where: { id },
});
res.json({ success: true });
} catch (error) {
console.error("Revoke device error:", error);
res.status(500).json({ error: "Failed to revoke device" });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+588
View File
@@ -0,0 +1,588 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { config } from "../config";
import { lidarrService } from "../services/lidarr";
import { musicBrainzService } from "../services/musicbrainz";
import { simpleDownloadManager } from "../services/simpleDownloadManager";
import crypto from "crypto";
const router = Router();
router.use(requireAuthOrToken);
// POST /downloads - Create download job
router.post("/", async (req, res) => {
try {
const {
type,
mbid,
subject,
artistName,
albumTitle,
downloadType = "library",
} = req.body;
const userId = req.user!.id;
if (!type || !mbid || !subject) {
return res.status(400).json({
error: "Missing required fields: type, mbid, subject",
});
}
if (type !== "artist" && type !== "album") {
return res
.status(400)
.json({ error: "Type must be 'artist' or 'album'" });
}
if (downloadType !== "library" && downloadType !== "discovery") {
return res.status(400).json({
error: "downloadType must be 'library' or 'discovery'",
});
}
// Check if Lidarr is enabled (database or .env)
const lidarrEnabled = await lidarrService.isEnabled();
if (!lidarrEnabled) {
return res.status(400).json({
error: "Lidarr not configured. Please add albums manually to your library.",
});
}
// Determine root folder path based on download type
const rootFolderPath =
downloadType === "discovery" ? "/music/discovery" : "/music";
if (type === "artist") {
// For artist downloads, fetch albums and create individual jobs
const jobs = await processArtistDownload(
userId,
mbid,
subject,
rootFolderPath,
downloadType
);
return res.json({
id: jobs[0]?.id || null,
status: "processing",
downloadType,
rootFolderPath,
message: `Creating download jobs for ${jobs.length} album(s)...`,
albumCount: jobs.length,
jobs: jobs.map((j) => ({ id: j.id, subject: j.subject })),
});
}
// Single album download - check for existing job first
const existingJob = await prisma.downloadJob.findFirst({
where: {
targetMbid: mbid,
status: { in: ["pending", "processing"] },
},
});
if (existingJob) {
console.log(`[DOWNLOAD] Job already exists for ${mbid}: ${existingJob.id} (${existingJob.status})`);
return res.json({
id: existingJob.id,
status: existingJob.status,
downloadType,
rootFolderPath,
message: "Download already in progress",
duplicate: true,
});
}
const job = await prisma.downloadJob.create({
data: {
userId,
subject,
type,
targetMbid: mbid,
status: "pending",
metadata: {
downloadType,
rootFolderPath,
artistName,
albumTitle,
},
},
});
console.log(
`[DOWNLOAD] Triggering Lidarr: ${type} "${subject}" -> ${rootFolderPath}`
);
// Process in background
processDownload(
job.id,
type,
mbid,
subject,
rootFolderPath,
artistName,
albumTitle
).catch((error) => {
console.error(
`Download processing failed for job ${job.id}:`,
error
);
});
res.json({
id: job.id,
status: job.status,
downloadType,
rootFolderPath,
message: "Download job created. Processing in background.",
});
} catch (error) {
console.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" });
}
});
/**
* Process artist download by creating individual album jobs
*/
async function processArtistDownload(
userId: string,
artistMbid: string,
artistName: string,
rootFolderPath: string,
downloadType: string
): Promise<{ id: string; subject: string }[]> {
console.log(`\n Processing artist download: ${artistName}`);
console.log(` Artist MBID: ${artistMbid}`);
// Generate a batch ID to group all album downloads
const batchId = crypto.randomUUID();
console.log(` Batch ID: ${batchId}`);
try {
// First, add the artist to Lidarr (this monitors all albums)
const lidarrArtist = await lidarrService.addArtist(
artistMbid,
artistName,
rootFolderPath
);
if (!lidarrArtist) {
console.log(` Failed to add artist to Lidarr`);
throw new Error("Failed to add artist to Lidarr");
}
console.log(` Artist added to Lidarr (ID: ${lidarrArtist.id})`);
// Fetch albums from MusicBrainz
const releaseGroups = await musicBrainzService.getReleaseGroups(
artistMbid,
["album", "ep"],
100
);
console.log(
` Found ${releaseGroups.length} albums/EPs from MusicBrainz`
);
if (releaseGroups.length === 0) {
console.log(` No albums found for artist`);
return [];
}
// Create individual album jobs
const jobs: { id: string; subject: string }[] = [];
for (const rg of releaseGroups) {
const albumMbid = rg.id;
const albumTitle = rg.title;
const albumSubject = `${artistName} - ${albumTitle}`;
// Check if we already have this album downloaded
const existingAlbum = await prisma.album.findFirst({
where: { rgMbid: albumMbid },
});
if (existingAlbum) {
console.log(` Skipping "${albumTitle}" - already in library`);
continue;
}
// Check if there's already a pending/processing job for this album
const existingJob = await prisma.downloadJob.findFirst({
where: {
targetMbid: albumMbid,
status: { in: ["pending", "processing"] },
},
});
if (existingJob) {
console.log(
` Skipping "${albumTitle}" - already in download queue`
);
continue;
}
// Create download job for this album
const now = new Date();
const job = await prisma.downloadJob.create({
data: {
userId,
subject: albumSubject,
type: "album",
targetMbid: albumMbid,
status: "pending",
metadata: {
downloadType,
rootFolderPath,
artistName,
artistMbid,
albumTitle,
batchId, // Link all albums in this artist download
batchArtist: artistName,
createdAt: now.toISOString(), // Track when job was created for timeout
},
},
});
jobs.push({ id: job.id, subject: albumSubject });
console.log(` [JOB] Created job for: ${albumSubject}`);
// Start the download in background
processDownload(
job.id,
"album",
albumMbid,
albumSubject,
rootFolderPath,
artistName,
albumTitle
).catch((error) => {
console.error(`Download failed for ${albumSubject}:`, error);
});
}
console.log(` Created ${jobs.length} album download jobs`);
return jobs;
} catch (error: any) {
console.error(` Failed to process artist download:`, error.message);
throw error;
}
}
// Background download processor
async function processDownload(
jobId: string,
type: string,
mbid: string,
subject: string,
rootFolderPath: string,
artistName?: string,
albumTitle?: string
) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) {
console.error(`Job ${jobId} not found`);
return;
}
if (type === "album") {
// For albums, use the simple download manager
let parsedArtist = artistName;
let parsedAlbum = albumTitle;
if (!parsedArtist || !parsedAlbum) {
const parts = subject.split(" - ");
if (parts.length >= 2) {
parsedArtist = parts[0].trim();
parsedAlbum = parts.slice(1).join(" - ").trim();
} else {
parsedArtist = subject;
parsedAlbum = subject;
}
}
console.log(`Parsed: Artist="${parsedArtist}", Album="${parsedAlbum}"`);
// Use simple download manager for album downloads
const result = await simpleDownloadManager.startDownload(
jobId,
parsedArtist,
parsedAlbum,
mbid,
job.userId
);
if (!result.success) {
console.error(`Failed to start download: ${result.error}`);
}
}
}
// DELETE /downloads/clear-all - Clear all download jobs for the current user
// IMPORTANT: Must be BEFORE /:id route to avoid catching "clear-all" as an ID
router.delete("/clear-all", async (req, res) => {
try {
const userId = req.user!.id;
const { status } = req.query;
const where: any = { userId };
if (status) {
where.status = status as string;
}
const result = await prisma.downloadJob.deleteMany({ where });
console.log(
` Cleared ${result.count} download jobs for user ${userId}`
);
res.json({ success: true, deleted: result.count });
} catch (error) {
console.error("Clear downloads error:", error);
res.status(500).json({ error: "Failed to clear downloads" });
}
});
// POST /downloads/clear-lidarr-queue - Clear stuck/failed items from Lidarr's queue
router.post("/clear-lidarr-queue", async (req, res) => {
try {
const result = await simpleDownloadManager.clearLidarrQueue();
res.json({
success: true,
removed: result.removed,
errors: result.errors,
});
} catch (error: any) {
console.error("Clear Lidarr queue error:", error);
res.status(500).json({ error: "Failed to clear Lidarr queue" });
}
});
// GET /downloads/failed - List failed/unavailable albums for the current user
// IMPORTANT: Must be BEFORE /:id route to avoid catching "failed" as an ID
router.get("/failed", async (req, res) => {
try {
const userId = req.user!.id;
const failedAlbums = await prisma.unavailableAlbum.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
});
res.json(failedAlbums);
} catch (error) {
console.error("List failed albums error:", error);
res.status(500).json({ error: "Failed to list failed albums" });
}
});
// DELETE /downloads/failed/:id - Dismiss a failed album notification
router.delete("/failed/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
// Verify ownership before deleting
const failedAlbum = await prisma.unavailableAlbum.findFirst({
where: { id, userId },
});
if (!failedAlbum) {
return res.status(404).json({ error: "Failed album not found" });
}
await prisma.unavailableAlbum.delete({
where: { id },
});
res.json({ success: true });
} catch (error) {
console.error("Delete failed album error:", error);
res.status(500).json({ error: "Failed to delete failed album" });
}
});
// GET /downloads/:id - Get download job status
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
const job = await prisma.downloadJob.findFirst({
where: {
id,
userId,
},
});
if (!job) {
return res.status(404).json({ error: "Download job not found" });
}
res.json(job);
} catch (error) {
console.error("Get download job error:", error);
res.status(500).json({ error: "Failed to get download job" });
}
});
// PATCH /downloads/:id - Update download job (e.g., mark as complete)
router.patch("/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
const { status } = req.body;
const job = await prisma.downloadJob.findFirst({
where: {
id,
userId,
},
});
if (!job) {
return res.status(404).json({ error: "Download job not found" });
}
const updated = await prisma.downloadJob.update({
where: { id },
data: {
status: status || "completed",
completedAt: status === "completed" ? new Date() : undefined,
},
});
res.json(updated);
} catch (error) {
console.error("Update download job error:", error);
res.status(500).json({ error: "Failed to update download job" });
}
});
// DELETE /downloads/:id - Delete download job
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
const userId = req.user!.id;
// Use deleteMany to handle race conditions gracefully
// This won't throw an error if the record was already deleted
const result = await prisma.downloadJob.deleteMany({
where: {
id,
userId,
},
});
// Return success even if nothing was deleted (idempotent delete)
res.json({ success: true, deleted: result.count > 0 });
} catch (error: any) {
console.error("Delete download job error:", error);
console.error("Error details:", error.message, error.stack);
res.status(500).json({
error: "Failed to delete download job",
details: error.message,
});
}
});
// GET /downloads - List user's download jobs
router.get("/", async (req, res) => {
try {
const userId = req.user!.id;
const { status, limit = "50", includeDiscovery = "false", includeCleared = "false" } = req.query;
const where: any = { userId };
if (status) {
where.status = status as string;
}
// Filter out cleared jobs by default (user dismissed from history)
if (includeCleared !== "true") {
where.cleared = false;
}
const jobs = await prisma.downloadJob.findMany({
where,
orderBy: { createdAt: "desc" },
take: parseInt(limit as string, 10),
});
// Filter out discovery downloads unless explicitly requested
// Discovery downloads are automated and shouldn't show in the UI popover
const filteredJobs =
includeDiscovery === "true"
? jobs
: jobs.filter((job) => {
const metadata = job.metadata as any;
return metadata?.downloadType !== "discovery";
});
res.json(filteredJobs);
} catch (error) {
console.error("List download jobs error:", error);
res.status(500).json({ error: "Failed to list download jobs" });
}
});
// POST /downloads/keep-track - Keep a discovery track (move to permanent library)
router.post("/keep-track", async (req, res) => {
try {
const { discoveryTrackId } = req.body;
const userId = req.user!.id;
if (!discoveryTrackId) {
return res.status(400).json({ error: "Missing discoveryTrackId" });
}
const discoveryTrack = await prisma.discoveryTrack.findUnique({
where: { id: discoveryTrackId },
include: {
discoveryAlbum: true,
},
});
if (!discoveryTrack) {
return res.status(404).json({ error: "Discovery track not found" });
}
// Mark as kept
await prisma.discoveryTrack.update({
where: { id: discoveryTrackId },
data: { userKept: true },
});
// If Lidarr enabled, create job to download full album to permanent library
const lidarrEnabled = await lidarrService.isEnabled();
if (lidarrEnabled) {
const job = await prisma.downloadJob.create({
data: {
userId,
subject: `${discoveryTrack.discoveryAlbum.albumTitle} by ${discoveryTrack.discoveryAlbum.artistName}`,
type: "album",
targetMbid: discoveryTrack.discoveryAlbum.rgMbid,
status: "pending",
},
});
return res.json({
success: true,
message:
"Track marked as kept. Full album will be downloaded to permanent library.",
downloadJobId: job.id,
});
}
res.json({
success: true,
message:
"Track marked as kept. Please add the full album manually to your /music folder.",
});
} catch (error) {
console.error("Keep track error:", error);
res.status(500).json({ error: "Failed to keep track" });
}
});
export default router;
+293
View File
@@ -0,0 +1,293 @@
import { Router } from "express";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { enrichmentService } from "../services/enrichment";
import { getEnrichmentProgress, runFullEnrichment } from "../workers/unifiedEnrichment";
const router = Router();
router.use(requireAuth);
/**
* GET /enrichment/progress
* Get comprehensive enrichment progress (artists, track tags, audio analysis)
*/
router.get("/progress", async (req, res) => {
try {
const progress = await getEnrichmentProgress();
res.json(progress);
} catch (error) {
console.error("Get enrichment progress error:", error);
res.status(500).json({ error: "Failed to get progress" });
}
});
/**
* POST /enrichment/full
* Trigger full enrichment (re-enriches everything regardless of status)
* Admin only
*/
router.post("/full", requireAdmin, async (req, res) => {
try {
// This runs in the background
runFullEnrichment().catch(err => {
console.error("Full enrichment error:", err);
});
res.json({
message: "Full enrichment started",
description: "All artists, track tags, and audio analysis will be re-processed"
});
} catch (error) {
console.error("Trigger full enrichment error:", error);
res.status(500).json({ error: "Failed to start full enrichment" });
}
});
/**
* GET /enrichment/settings
* Get enrichment settings for current user
*/
router.get("/settings", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.getSettings(userId);
res.json(settings);
} catch (error) {
console.error("Get enrichment settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
/**
* PUT /enrichment/settings
* Update enrichment settings for current user
*/
router.put("/settings", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.updateSettings(userId, req.body);
res.json(settings);
} catch (error) {
console.error("Update enrichment settings error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
});
/**
* POST /enrichment/artist/:id
* Enrich a single artist
*/
router.post("/artist/:id", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.getSettings(userId);
if (!settings.enabled) {
return res.status(400).json({ error: "Enrichment is not enabled" });
}
const enrichmentData = await enrichmentService.enrichArtist(req.params.id, settings);
if (!enrichmentData) {
return res.status(404).json({ error: "No enrichment data found" });
}
if (enrichmentData.confidence > 0.3) {
await enrichmentService.applyArtistEnrichment(req.params.id, enrichmentData);
}
res.json({
success: true,
confidence: enrichmentData.confidence,
data: enrichmentData,
});
} catch (error: any) {
console.error("Enrich artist error:", error);
res.status(500).json({ error: error.message || "Failed to enrich artist" });
}
});
/**
* POST /enrichment/album/:id
* Enrich a single album
*/
router.post("/album/:id", async (req, res) => {
try {
const userId = req.user!.id;
const settings = await enrichmentService.getSettings(userId);
if (!settings.enabled) {
return res.status(400).json({ error: "Enrichment is not enabled" });
}
const enrichmentData = await enrichmentService.enrichAlbum(req.params.id, settings);
if (!enrichmentData) {
return res.status(404).json({ error: "No enrichment data found" });
}
if (enrichmentData.confidence > 0.3) {
await enrichmentService.applyAlbumEnrichment(req.params.id, enrichmentData);
}
res.json({
success: true,
confidence: enrichmentData.confidence,
data: enrichmentData,
});
} catch (error: any) {
console.error("Enrich album error:", error);
res.status(500).json({ error: error.message || "Failed to enrich album" });
}
});
/**
* POST /enrichment/start
* Start library-wide enrichment (runs in background)
*/
router.post("/start", async (req, res) => {
try {
const userId = req.user!.id;
const { notificationService } = await import("../services/notificationService");
// Check if enrichment is enabled in system settings
const { prisma } = await import("../utils/db");
const systemSettings = await prisma.systemSettings.findUnique({
where: { id: "default" },
select: { autoEnrichMetadata: true },
});
if (!systemSettings?.autoEnrichMetadata) {
return res.status(400).json({ error: "Enrichment is not enabled. Enable it in settings first." });
}
// Get user enrichment settings or use defaults
const settings = await enrichmentService.getSettings(userId);
// Override enabled flag with system setting
settings.enabled = true;
// Send notification that enrichment is starting
await notificationService.notifySystem(
userId,
"Library Enrichment Started",
"Enriching artist metadata in the background..."
);
// Start enrichment in background
enrichmentService.enrichLibrary(userId).then(async () => {
// Send notification when complete
await notificationService.notifySystem(
userId,
"Library Enrichment Complete",
"All artist metadata has been enriched"
);
}).catch(async (error) => {
console.error("Background enrichment failed:", error);
await notificationService.create({
userId,
type: "error",
title: "Enrichment Failed",
message: error.message || "Failed to enrich library metadata",
});
});
res.json({
success: true,
message: "Library enrichment started in background",
});
} catch (error: any) {
console.error("Start enrichment error:", error);
res.status(500).json({ error: error.message || "Failed to start enrichment" });
}
});
/**
* PUT /library/artists/:id/metadata
* Update artist metadata manually
*/
router.put("/artists/:id/metadata", async (req, res) => {
try {
const { name, bio, genres, mbid, heroUrl } = req.body;
const updateData: any = {};
if (name) updateData.name = name;
if (bio) updateData.summary = bio;
if (mbid) updateData.mbid = mbid;
if (heroUrl) updateData.heroUrl = heroUrl;
if (genres) updateData.manualGenres = JSON.stringify(genres);
// Mark as manually edited
updateData.manuallyEdited = true;
const { prisma } = await import("../utils/db");
const artist = await prisma.artist.update({
where: { id: req.params.id },
data: updateData,
include: {
albums: {
select: {
id: true,
title: true,
year: true,
coverUrl: true,
},
},
},
});
res.json(artist);
} catch (error: any) {
console.error("Update artist metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update artist" });
}
});
/**
* PUT /library/albums/:id/metadata
* Update album metadata manually
*/
router.put("/albums/:id/metadata", async (req, res) => {
try {
const { title, year, genres, rgMbid, coverUrl } = req.body;
const updateData: any = {};
if (title) updateData.title = title;
if (year) updateData.year = parseInt(year);
if (rgMbid) updateData.rgMbid = rgMbid;
if (coverUrl) updateData.coverUrl = coverUrl;
if (genres) updateData.manualGenres = JSON.stringify(genres);
// Mark as manually edited
updateData.manuallyEdited = true;
const { prisma } = await import("../utils/db");
const album = await prisma.album.update({
where: { id: req.params.id },
data: updateData,
include: {
artist: {
select: {
id: true,
name: true,
},
},
tracks: {
select: {
id: true,
title: true,
trackNo: true,
duration: true,
},
},
},
});
res.json(album);
} catch (error: any) {
console.error("Update album metadata error:", error);
res.status(500).json({ error: error.message || "Failed to update album" });
}
});
export default router;
+187
View File
@@ -0,0 +1,187 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
const router = Router();
// All routes require auth (session or API key)
router.use(requireAuthOrToken);
/**
* GET /homepage/genres
* Get top genres from user's library with sample albums
*/
router.get("/genres", async (req, res) => {
try {
const { limit = "4" } = req.query; // Get top 4 genres by default
const limitNum = parseInt(limit as string, 10);
// Check Redis cache first (cache for 24 hours)
const cacheKey = `homepage:genres:${limitNum}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[HOMEPAGE] Cache HIT for genres`);
return res.json(JSON.parse(cached));
}
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
}
console.log(
`[HOMEPAGE] ✗ Cache MISS for genres, fetching from database...`
);
// Get all albums with genres (excluding discovery albums)
const albums = await prisma.album.findMany({
where: {
genres: {
isEmpty: false, // Only albums with genres
},
location: "LIBRARY", // Exclude discovery albums
},
select: {
id: true,
title: true,
year: true,
coverUrl: true,
genres: true,
artistId: true,
artist: {
select: {
id: true,
name: true,
},
},
},
});
// Count genre occurrences
const genreCounts = new Map<string, number>();
for (const album of albums) {
for (const genre of album.genres) {
genreCounts.set(genre, (genreCounts.get(genre) || 0) + 1);
}
}
// Get top genres
const topGenres = Array.from(genreCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limitNum)
.map(([genre]) => genre);
console.log(`[HOMEPAGE] Top genres: ${topGenres.join(", ")}`);
// For each top genre, get sample albums (up to 10)
const genresWithAlbums = topGenres.map((genre) => {
const genreAlbums = albums
.filter((a) => a.genres.includes(genre))
.slice(0, 10)
.map((a) => ({
id: a.id,
title: a.title,
year: a.year,
coverArt: a.coverUrl,
artist: {
id: a.artist.id,
name: a.artist.name,
},
}));
return {
genre,
albums: genreAlbums,
totalCount: genreCounts.get(genre) || 0,
};
});
// Cache for 24 hours
try {
await redisClient.setEx(
cacheKey,
24 * 60 * 60,
JSON.stringify(genresWithAlbums)
);
console.log(`[HOMEPAGE] Cached genres for 24 hours`);
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
}
res.json(genresWithAlbums);
} catch (error) {
console.error("Get homepage genres error:", error);
res.status(500).json({ error: "Failed to fetch genres" });
}
});
/**
* GET /homepage/top-podcasts
* Get top podcasts (most subscribed or most recent episodes)
*/
router.get("/top-podcasts", async (req, res) => {
try {
const { limit = "6" } = req.query; // Get top 6 podcasts by default
const limitNum = parseInt(limit as string, 10);
// Check Redis cache first (cache for 24 hours)
const cacheKey = `homepage:top-podcasts:${limitNum}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[HOMEPAGE] Cache HIT for top podcasts`);
return res.json(JSON.parse(cached));
}
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache read error:", cacheError);
}
console.log(
`[HOMEPAGE] ✗ Cache MISS for top podcasts, fetching from database...`
);
// Get podcasts with episode counts
const podcasts = await prisma.podcast.findMany({
take: limitNum,
orderBy: { createdAt: "desc" }, // Most recently added
select: {
id: true,
title: true,
author: true,
description: true,
imageUrl: true,
_count: {
select: { episodes: true },
},
},
});
const result = podcasts.map((podcast) => ({
id: podcast.id,
title: podcast.title,
author: podcast.author,
description: podcast.description?.substring(0, 150) + "...",
coverArt: podcast.imageUrl,
episodeCount: podcast._count.episodes,
}));
// Cache for 24 hours
try {
await redisClient.setEx(
cacheKey,
24 * 60 * 60,
JSON.stringify(result)
);
console.log(`[HOMEPAGE] Cached top podcasts for 24 hours`);
} catch (cacheError) {
console.warn("[HOMEPAGE] Redis cache write error:", cacheError);
}
res.json(result);
} catch (error) {
console.error("Get top podcasts error:", error);
res.status(500).json({ error: "Failed to fetch top podcasts" });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const listeningStateSchema = z.object({
kind: z.enum(["music", "book"]),
entityId: z.string(),
trackId: z.string().optional(),
positionMs: z.number().int().min(0),
});
// POST /listening-state
router.post("/", async (req, res) => {
try {
const userId = req.session.userId!;
const data = listeningStateSchema.parse(req.body);
const state = await prisma.listeningState.upsert({
where: {
userId_kind_entityId: {
userId,
kind: data.kind,
entityId: data.entityId,
},
},
create: {
userId,
...data,
},
update: {
trackId: data.trackId,
positionMs: data.positionMs,
updatedAt: new Date(),
},
});
res.json(state);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Update listening state error:", error);
res.status(500).json({ error: "Failed to update listening state" });
}
});
// GET /listening-state
router.get("/", async (req, res) => {
try {
const userId = req.session.userId!;
const { kind, entityId } = req.query;
if (!kind || !entityId) {
return res
.status(400)
.json({ error: "kind and entityId required" });
}
const state = await prisma.listeningState.findUnique({
where: {
userId_kind_entityId: {
userId,
kind: kind as string,
entityId: entityId as string,
},
},
});
if (!state) {
return res.status(404).json({ error: "No listening state found" });
}
res.json(state);
} catch (error) {
console.error("Get listening state error:", error);
res.status(500).json({ error: "Failed to get listening state" });
}
});
// GET /listening-state/recent (for "Continue Listening")
router.get("/recent", async (req, res) => {
try {
const userId = req.session.userId!;
const { limit = "10" } = req.query;
const states = await prisma.listeningState.findMany({
where: { userId },
orderBy: { updatedAt: "desc" },
take: parseInt(limit as string, 10),
});
res.json(states);
} catch (error) {
console.error("Get recent listening states error:", error);
res.status(500).json({
error: "Failed to get recent listening states",
});
}
});
export default router;
+684
View File
@@ -0,0 +1,684 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { programmaticPlaylistService } from "../services/programmaticPlaylists";
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
const router = Router();
router.use(requireAuthOrToken);
const getRequestUserId = (req: any): string | null => {
return req.user?.id || req.session?.userId || null;
};
/**
* @openapi
* /mixes:
* get:
* summary: Get all programmatic mixes
* description: Returns all auto-generated mixes (era-based, genre-based, top tracks, rediscover, artist similar, random discovery)
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* responses:
* 200:
* description: List of programmatic mixes
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: "era-2000"
* type:
* type: string
* enum: [era, genre, top-tracks, rediscover, artist-similar, random-discovery]
* name:
* type: string
* example: "Your 2000s Mix"
* description:
* type: string
* example: "Music from the 2000s in your library"
* trackIds:
* type: array
* items:
* type: string
* coverUrls:
* type: array
* items:
* type: string
* description: Album covers for mosaic display (up to 4)
* trackCount:
* type: integer
* example: 42
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// Check cache first (mixes are expensive to compute)
const cacheKey = `mixes:${userId}`;
const cached = await redisClient.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
// Generate all mixes
const mixes = await programmaticPlaylistService.generateAllMixes(
userId
);
// Cache for 1 hour
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
res.json(mixes);
} catch (error) {
console.error("Get mixes error:", error);
res.status(500).json({ error: "Failed to get mixes" });
}
});
/**
* @openapi
* /mixes/mood:
* post:
* summary: Generate a custom mood-based mix on demand
* description: Creates a personalized mix based on audio features like valence, energy, tempo, etc.
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* valence:
* type: object
* properties:
* min:
* type: number
* minimum: 0
* maximum: 1
* max:
* type: number
* minimum: 0
* maximum: 1
* energy:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* danceability:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* acousticness:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* instrumentalness:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* bpm:
* type: object
* properties:
* min:
* type: number
* max:
* type: number
* keyScale:
* type: string
* enum: [major, minor]
* limit:
* type: integer
* default: 15
* responses:
* 200:
* description: Generated mood mix with full track details
* 400:
* description: Not enough tracks matching criteria
* 401:
* description: Not authenticated
*/
router.post("/mood", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const params = req.body;
// Validate parameters
const validKeys = [
// Basic audio features
'valence', 'energy', 'danceability', 'acousticness', 'instrumentalness', 'arousal', 'bpm', 'keyScale',
// ML mood predictions
'moodHappy', 'moodSad', 'moodRelaxed', 'moodAggressive', 'moodParty', 'moodAcoustic', 'moodElectronic',
// Other
'limit'
];
for (const key of Object.keys(params)) {
if (!validKeys.includes(key)) {
return res.status(400).json({ error: `Invalid parameter: ${key}` });
}
}
const mix = await programmaticPlaylistService.generateMoodOnDemand(userId, params);
if (!mix) {
return res.status(400).json({
error: "Not enough tracks matching your criteria",
suggestion: "Try widening your parameters or wait for more tracks to be analyzed"
});
}
// Load full track details
const tracks = await prisma.track.findMany({
where: {
id: { in: mix.trackIds },
},
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
});
// Preserve mix order
const orderedTracks = mix.trackIds
.map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined);
console.log(`[MIXES] Generated mood-on-demand mix with ${mix.trackCount} tracks`);
res.json({
...mix,
tracks: orderedTracks,
});
} catch (error) {
console.error("Generate mood mix error:", error);
res.status(500).json({ error: "Failed to generate mood mix" });
}
});
/**
* Available mood presets for the UI
*/
router.get("/mood/presets", async (req, res) => {
// Presets use ML mood predictions for more accurate matching
// These mirror the logic used in programmatic mixes (Chill Mix, Party Mix, etc.)
const presets = [
{
id: "happy",
name: "Happy & Upbeat",
color: "from-yellow-400 to-orange-500",
params: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 }, energy: { min: 0.4 } },
},
{
id: "sad",
name: "Melancholic",
color: "from-blue-600 to-indigo-700",
params: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
},
{
id: "chill",
name: "Chill & Relaxed",
color: "from-teal-400 to-cyan-500",
params: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 }, energy: { max: 0.55 } },
},
{
id: "energetic",
name: "High Energy",
color: "from-red-500 to-orange-600",
params: { arousal: { min: 0.6 }, energy: { min: 0.65 }, moodRelaxed: { max: 0.4 } },
},
{
id: "focus",
name: "Focus Mode",
color: "from-purple-600 to-violet-700",
params: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 }, energy: { min: 0.2, max: 0.6 } },
},
{
id: "dance",
name: "Dance Party",
color: "from-pink-500 to-rose-600",
params: { moodParty: { min: 0.5 }, danceability: { min: 0.6 }, energy: { min: 0.5 } },
},
{
id: "acoustic",
name: "Acoustic Vibes",
color: "from-amber-500 to-yellow-600",
params: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
},
{
id: "dark",
name: "Dark & Moody",
color: "from-gray-700 to-slate-800",
params: { moodAggressive: { min: 0.4 }, moodHappy: { max: 0.4 }, keyScale: "minor" },
},
{
id: "romantic",
name: "Romantic",
color: "from-rose-500 to-pink-600",
params: { moodRelaxed: { min: 0.3 }, moodAggressive: { max: 0.3 }, acousticness: { min: 0.3 }, energy: { max: 0.6 } },
},
{
id: "workout",
name: "Workout Beast",
color: "from-green-500 to-emerald-600",
params: { arousal: { min: 0.6 }, energy: { min: 0.7 }, moodRelaxed: { max: 0.4 }, bpm: { min: 110 } },
},
{
id: "sleepy",
name: "Sleep & Unwind",
color: "from-indigo-400 to-purple-500",
params: { moodRelaxed: { min: 0.5 }, energy: { max: 0.35 }, moodAggressive: { max: 0.2 } },
},
{
id: "confident",
name: "Confidence Boost",
color: "from-amber-400 to-orange-500",
params: { moodHappy: { min: 0.4 }, moodParty: { min: 0.3 }, energy: { min: 0.5 }, danceability: { min: 0.5 } },
},
];
res.json(presets);
});
/**
* Save user's mood mix preferences
* These preferences are used to generate "Your Mood Mix" in the mix rotation
*/
router.post("/mood/save-preferences", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const params = req.body;
// Validate that at least some params are provided
if (!params || Object.keys(params).length === 0) {
return res.status(400).json({ error: "No mood parameters provided" });
}
// Save to user record
await prisma.user.update({
where: { id: userId },
data: { moodMixParams: params }
});
// Invalidate mix cache so the new mood mix appears
const cacheKey = `mixes:${userId}`;
await redisClient.del(cacheKey);
console.log(`[MIXES] Saved mood mix preferences for user ${userId}`);
res.json({ success: true, message: "Mood preferences saved" });
} catch (error) {
console.error("Save mood preferences error:", error);
res.status(500).json({ error: "Failed to save mood preferences" });
}
});
/**
* @openapi
* /mixes/refresh:
* post:
* summary: Force refresh all mixes
* description: Clears cache and regenerates all programmatic mixes
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* responses:
* 200:
* description: Mixes refreshed successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: "Mixes refreshed"
* mixes:
* type: array
* items:
* type: object
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post("/refresh", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
// Clear cache
const cacheKey = `mixes:${userId}`;
await redisClient.del(cacheKey);
// Regenerate mixes with random selection (not date-based)
const mixes = await programmaticPlaylistService.generateAllMixes(
userId,
true
);
// Cache for 1 hour
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
res.json({ message: "Mixes refreshed", mixes });
} catch (error) {
console.error("Refresh mixes error:", error);
res.status(500).json({ error: "Failed to refresh mixes" });
}
});
/**
* @openapi
* /mixes/{id}/save:
* post:
* summary: Save a mix as a playlist
* description: Creates a new playlist with all tracks from the specified mix
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Mix ID to save as playlist
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description: Optional custom name for the playlist (defaults to mix name)
* responses:
* 200:
* description: Playlist created successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* name:
* type: string
* trackCount:
* type: integer
* 404:
* description: Mix not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.post("/:id/save", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const mixId = req.params.id;
const customName = req.body.name;
// Get the mix with track details
const cacheKey = `mixes:${userId}`;
let mixes;
const cached = await redisClient.get(cacheKey);
if (cached) {
mixes = JSON.parse(cached);
} else {
mixes = await programmaticPlaylistService.generateAllMixes(userId);
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
}
const mix = mixes.find((m: any) => m.id === mixId);
if (!mix) {
return res.status(404).json({ error: "Mix not found" });
}
const existingPlaylist = await prisma.playlist.findFirst({
where: {
userId,
mixId: mix.id,
},
select: {
id: true,
name: true,
},
});
if (existingPlaylist) {
return res.status(409).json({
error: "Mix already saved as playlist",
playlistId: existingPlaylist.id,
name: existingPlaylist.name,
});
}
// Create playlist
const playlist = await prisma.playlist.create({
data: {
userId,
mixId: mix.id,
name: customName || mix.name,
isPublic: false,
},
});
// Add all tracks to the playlist
const playlistItems = mix.trackIds.map(
(trackId: string, index: number) => ({
playlistId: playlist.id,
trackId,
sort: index,
})
);
await prisma.playlistItem.createMany({
data: playlistItems,
});
console.log(
`[MIXES] Saved mix ${mixId} as playlist ${playlist.id} (${mix.trackIds.length} tracks)`
);
res.json({
id: playlist.id,
name: playlist.name,
trackCount: mix.trackIds.length,
});
} catch (error) {
console.error("Save mix as playlist error:", error);
res.status(500).json({ error: "Failed to save mix as playlist" });
}
});
/**
* @openapi
* /mixes/{id}:
* get:
* summary: Get a specific mix with full track details
* tags: [Mixes]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Mix ID (e.g., "era-2000", "genre-rock", "top-tracks")
* responses:
* 200:
* description: Mix with full track details
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* type:
* type: string
* name:
* type: string
* description:
* type: string
* trackIds:
* type: array
* items:
* type: string
* coverUrls:
* type: array
* items:
* type: string
* trackCount:
* type: integer
* tracks:
* type: array
* items:
* $ref: '#/components/schemas/Track'
* 404:
* description: Mix not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/:id", async (req, res) => {
try {
const userId = getRequestUserId(req);
if (!userId) {
return res.status(401).json({ error: "Not authenticated" });
}
const mixId = req.params.id;
// Get all mixes (from cache if available)
const cacheKey = `mixes:${userId}`;
let mixes;
const cached = await redisClient.get(cacheKey);
if (cached) {
mixes = JSON.parse(cached);
} else {
mixes = await programmaticPlaylistService.generateAllMixes(userId);
await redisClient.setEx(cacheKey, 3600, JSON.stringify(mixes));
}
// Find the specific mix
const mix = mixes.find((m: any) => m.id === mixId);
if (!mix) {
return res.status(404).json({ error: "Mix not found" });
}
// Load full track details
const tracks = await prisma.track.findMany({
where: {
id: {
in: mix.trackIds,
},
},
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
});
// Preserve mix order
const orderedTracks = mix.trackIds
.map((id: string) => tracks.find((t) => t.id === id))
.filter((t: any) => t !== undefined);
res.json({
...mix,
tracks: orderedTracks,
});
} catch (error) {
console.error("Get mix error:", error);
res.status(500).json({ error: "Failed to get mix" });
}
});
export default router;
+711
View File
@@ -0,0 +1,711 @@
import { Router, Response } from "express";
import { notificationService } from "../services/notificationService";
import { AuthenticatedRequest, requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
const router = Router();
/**
* GET /notifications
* Get all uncleared notifications for the current user
*/
router.get(
"/",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
console.log(
`[Notifications] Fetching notifications for user ${
req.user!.id
}`
);
const notifications = await notificationService.getForUser(
req.user!.id
);
console.log(
`[Notifications] Found ${notifications.length} notifications`
);
res.json(notifications);
} catch (error: any) {
console.error("Error fetching notifications:", error);
res.status(500).json({ error: "Failed to fetch notifications" });
}
}
);
/**
* GET /notifications/unread-count
* Get count of unread notifications
*/
router.get(
"/unread-count",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
const count = await notificationService.getUnreadCount(
req.user!.id
);
res.json({ count });
} catch (error: any) {
console.error("Error fetching unread count:", error);
res.status(500).json({ error: "Failed to fetch unread count" });
}
}
);
/**
* POST /notifications/:id/read
* Mark a notification as read
*/
router.post(
"/:id/read",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await notificationService.markAsRead(req.params.id, req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error marking notification as read:", error);
res.status(500).json({
error: "Failed to mark notification as read",
});
}
}
);
/**
* POST /notifications/read-all
* Mark all notifications as read
*/
router.post(
"/read-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await notificationService.markAllAsRead(req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error marking all notifications as read:", error);
res.status(500).json({
error: "Failed to mark all notifications as read",
});
}
}
);
/**
* POST /notifications/:id/clear
* Clear (dismiss) a notification
*/
router.post(
"/:id/clear",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await notificationService.clear(req.params.id, req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing notification:", error);
res.status(500).json({ error: "Failed to clear notification" });
}
}
);
/**
* POST /notifications/clear-all
* Clear all notifications
*/
router.post(
"/clear-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await notificationService.clearAll(req.user!.id);
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing all notifications:", error);
res.status(500).json({
error: "Failed to clear all notifications",
});
}
}
);
// ============================================
// Download History Endpoints
// ============================================
/**
* GET /notifications/downloads/history
* Get completed/failed downloads that haven't been cleared
*/
router.get(
"/downloads/history",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
userId: req.user!.id,
status: { in: ["completed", "failed", "exhausted"] },
cleared: false,
},
orderBy: { updatedAt: "desc" },
take: 50,
});
res.json(downloads);
} catch (error: any) {
console.error("Error fetching download history:", error);
res.status(500).json({ error: "Failed to fetch download history" });
}
}
);
/**
* GET /notifications/downloads/active
* Get active downloads (pending/processing)
*/
router.get(
"/downloads/active",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
const downloads = await prisma.downloadJob.findMany({
where: {
userId: req.user!.id,
status: { in: ["pending", "processing"] },
},
orderBy: { createdAt: "desc" },
});
res.json(downloads);
} catch (error: any) {
console.error("Error fetching active downloads:", error);
res.status(500).json({ error: "Failed to fetch active downloads" });
}
}
);
/**
* POST /notifications/downloads/:id/clear
* Clear a download from history
*/
router.post(
"/downloads/:id/clear",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
id: req.params.id,
userId: req.user!.id,
},
data: { cleared: true },
});
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing download:", error);
res.status(500).json({ error: "Failed to clear download" });
}
}
);
/**
* POST /notifications/downloads/clear-all
* Clear all completed/failed downloads from history
*/
router.post(
"/downloads/clear-all",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
await prisma.downloadJob.updateMany({
where: {
userId: req.user!.id,
status: { in: ["completed", "failed", "exhausted"] },
cleared: false,
},
data: { cleared: true },
});
res.json({ success: true });
} catch (error: any) {
console.error("Error clearing all downloads:", error);
res.status(500).json({ error: "Failed to clear all downloads" });
}
}
);
/**
* POST /notifications/downloads/:id/retry
* Retry a failed download
*/
router.post(
"/downloads/:id/retry",
requireAuth,
async (req: AuthenticatedRequest, res: Response) => {
try {
// Get the failed download
const failedJob = await prisma.downloadJob.findFirst({
where: {
id: req.params.id,
userId: req.user!.id,
status: { in: ["failed", "exhausted"] },
},
});
if (!failedJob) {
return res
.status(404)
.json({ error: "Download not found or not failed" });
}
// If this was a pending-track retry job, re-run the pending-track retry flow
const metadata = failedJob.metadata as Record<
string,
unknown
> | null;
if (metadata?.downloadType === "pending-track-retry") {
const playlistId = metadata.playlistId as string | undefined;
const pendingTrackId = metadata.pendingTrackId as
| string
| undefined;
if (!playlistId || !pendingTrackId) {
return res.status(400).json({
error: "Cannot retry: missing playlistId or pendingTrackId",
});
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Validate playlist ownership and pending track exists
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist || playlist.userId !== req.user!.id) {
return res
.status(404)
.json({ error: "Playlist not found" });
}
const pendingTrack =
await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
return res
.status(404)
.json({ error: "Pending track not found" });
}
const retryTargetId =
pendingTrack.albumMbid ||
pendingTrack.artistMbid ||
`pendingTrack:${pendingTrack.id}`;
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
type: "track",
targetMbid: retryTargetId,
artistMbid: pendingTrack.artistMbid,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
downloadType: "pending-track-retry",
source: "soulseek",
playlistId,
pendingTrackId,
spotifyArtist: pendingTrack.spotifyArtist,
spotifyTitle: pendingTrack.spotifyTitle,
spotifyAlbum: pendingTrack.spotifyAlbum,
albumMbid: pendingTrack.albumMbid,
},
},
});
const { soulseekService } = await import(
"../services/soulseek"
);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.musicPath) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Music path not configured",
});
}
if (
!settings?.soulseekUsername ||
!settings?.soulseekPassword
) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Soulseek credentials not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Soulseek credentials not configured",
});
}
const albumName =
pendingTrack.spotifyAlbum !== "Unknown Album"
? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist;
const searchResult = await soulseekService.searchTrack(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (
!searchResult.found ||
searchResult.allMatches.length === 0
) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "No matching files found",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "No matching files found",
});
}
// Start download in background (don't await)
soulseekService
.downloadBestMatch(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle,
albumName,
searchResult.allMatches,
settings.musicPath
)
.then(async (result) => {
if (result.success) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "completed",
completedAt: new Date(),
metadata: {
...(newJobRecord.metadata as any),
filePath: result.filePath,
},
},
});
try {
const { scanQueue } = await import(
"../workers/queues"
);
await scanQueue.add(
"scan",
{
userId: req.user!.id,
source: "retry-pending-track",
albumMbid:
pendingTrack.albumMbid || undefined,
artistMbid:
pendingTrack.artistMbid ||
undefined,
},
{
priority: 1,
removeOnComplete: true,
}
);
} catch {
// Best-effort; job status already reflects download
}
} else {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: result.error || "Download failed",
completedAt: new Date(),
},
});
}
})
.catch(async (error) => {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: error?.message || "Download exception",
completedAt: new Date(),
},
});
});
return res.json({ success: true, newJobId: newJobRecord.id });
}
// If this was a spotify_import job, retry with Soulseek first
if (metadata?.downloadType === "spotify_import") {
const artistName = metadata.artistName as string;
const albumTitle = metadata.albumTitle as string;
if (!artistName || !albumTitle) {
return res
.status(400)
.json({
error: "Cannot retry: missing artist/album info",
});
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Create a NEW download job record for the retry
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
type: "album",
targetMbid:
failedJob.targetMbid || `retry_${Date.now()}`,
artistMbid: failedJob.artistMbid,
subject: `${artistName} - ${albumTitle}`,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
...metadata,
retryAttempt: true,
},
},
});
// Try Soulseek first (async)
const { soulseekService } = await import(
"../services/soulseek"
);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.json({
success: false,
newJobId: newJobRecord.id,
error: "Music path not configured",
});
}
// Build track from album info (single track search using album as title)
const tracks = [
{
artist: artistName,
title: albumTitle,
album: albumTitle,
},
];
console.log(
`[Retry] Trying Soulseek for ${artistName} - ${albumTitle}`
);
// Run Soulseek search async
soulseekService
.searchAndDownloadBatch(tracks, musicPath, 4)
.then(async (result) => {
if (result.successful > 0) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "completed",
completedAt: new Date(),
error: null,
metadata: {
...metadata,
source: "soulseek",
tracksDownloaded: result.successful,
files: result.files,
},
},
});
console.log(
`[Retry] ✓ Soulseek downloaded ${result.successful} tracks for ${artistName} - ${albumTitle}`
);
// Trigger library scan
const { scanQueue } = await import(
"../workers/queues"
);
await scanQueue.add("scan", {
paths: [],
fullScan: false,
userId: req.user!.id,
source: "retry-spotify-import",
});
} else {
// Soulseek failed, try Lidarr if we have an MBID
console.log(
`[Retry] Soulseek failed, trying Lidarr for ${artistName} - ${albumTitle}`
);
if (
failedJob.targetMbid &&
!failedJob.targetMbid.startsWith("retry_")
) {
const { simpleDownloadManager } = await import(
"../services/simpleDownloadManager"
);
const lidarrResult =
await simpleDownloadManager.startDownload(
newJobRecord.id,
artistName,
albumTitle,
failedJob.targetMbid,
req.user!.id,
false
);
if (!lidarrResult.success) {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error:
lidarrResult.error ||
"Both Soulseek and Lidarr failed",
completedAt: new Date(),
},
});
}
} else {
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: "No tracks found on Soulseek, no MBID for Lidarr fallback",
completedAt: new Date(),
},
});
}
}
})
.catch(async (error) => {
console.error(`[Retry] Soulseek error:`, error);
await prisma.downloadJob.update({
where: { id: newJobRecord.id },
data: {
status: "failed",
error: error?.message || "Soulseek error",
completedAt: new Date(),
},
});
});
return res.json({ success: true, newJobId: newJobRecord.id });
}
// Validate that we have the required MBIDs
if (!failedJob.targetMbid) {
return res
.status(400)
.json({ error: "Cannot retry: missing album MBID" });
}
// Mark old job as cleared
await prisma.downloadJob.update({
where: { id: failedJob.id },
data: { cleared: true },
});
// Extract parameters from the failed job
// Subject is typically "Artist - Album" format
const subjectParts = failedJob.subject.split(" - ");
const artistName = subjectParts[0] || failedJob.subject;
const albumTitle =
(metadata?.albumTitle as string) ||
subjectParts[1] ||
failedJob.subject;
// Create a NEW download job record for the retry
const newJobRecord = await prisma.downloadJob.create({
data: {
userId: req.user!.id,
type: failedJob.type as "artist" | "album",
targetMbid: failedJob.targetMbid,
artistMbid: failedJob.artistMbid,
subject: failedJob.subject,
status: "pending",
metadata: metadata || {},
},
});
// Import the download manager dynamically to avoid circular deps
const { simpleDownloadManager } = await import(
"../services/simpleDownloadManager"
);
// Start download with the correct positional arguments
// startDownload(jobId, artistName, albumTitle, albumMbid, userId, isDiscovery)
const result = await simpleDownloadManager.startDownload(
newJobRecord.id,
artistName,
albumTitle,
failedJob.targetMbid,
req.user!.id,
false // isDiscovery
);
res.json({
success: result.success,
newJobId: newJobRecord.id,
error: result.error,
});
} catch (error: any) {
console.error("Error retrying download:", error);
res.status(500).json({ error: "Failed to retry download" });
}
}
);
export default router;
+286
View File
@@ -0,0 +1,286 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const downloadAlbumSchema = z.object({
quality: z.enum(["original", "high", "medium", "low"]).optional(),
});
// POST /offline/albums/:id/download
router.post("/albums/:id/download", async (req, res) => {
try {
const userId = req.session.userId!;
const albumId = req.params.id;
const { quality } = downloadAlbumSchema.parse(req.body);
// Get user's default quality if not specified
let selectedQuality = quality;
if (!selectedQuality) {
const settings = await prisma.userSettings.findUnique({
where: { userId },
});
selectedQuality = (settings?.playbackQuality as any) || "medium";
}
// Get album with tracks
const album = await prisma.album.findUnique({
where: { id: albumId },
include: {
tracks: {
orderBy: { trackNo: "asc" },
},
artist: {
select: {
name: true,
},
},
},
});
if (!album) {
return res.status(404).json({ error: "Album not found" });
}
// Calculate total size estimate
const avgSizeMb: Record<string, number> = {
original: 30, // FLAC
high: 10, // MP3 320
medium: 6, // MP3 192
low: 4, // MP3 128
};
const estimatedSizeMb =
album.tracks.length * avgSizeMb[selectedQuality];
// Check user's cache limit
const settings = await prisma.userSettings.findUnique({
where: { userId },
});
if (settings) {
const currentCacheSize = await prisma.cachedTrack.aggregate({
where: { userId },
_sum: { fileSizeMb: true },
});
const currentSize = currentCacheSize._sum.fileSizeMb || 0;
if (currentSize + estimatedSizeMb > settings.maxCacheSizeMb) {
return res.status(400).json({
error: "Cache size limit exceeded",
currentSize,
maxSize: settings.maxCacheSizeMb,
needed: estimatedSizeMb,
});
}
}
// Create download job (tracks to be downloaded by mobile client)
const downloadJob = {
albumId: album.id,
albumTitle: album.title,
artistName: album.artist.name,
quality: selectedQuality,
tracks: album.tracks.map((track) => ({
trackId: track.id,
title: track.title,
trackNo: track.trackNo,
duration: track.duration,
streamUrl: `/library/tracks/${track.id}/stream?quality=${selectedQuality}`,
})),
estimatedSizeMb,
};
res.json(downloadJob);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create download job error:", error);
res.status(500).json({ error: "Failed to create download job" });
}
});
// POST /offline/tracks/:id/complete (called by mobile after download)
router.post("/tracks/:id/complete", async (req, res) => {
try {
const userId = req.session.userId!;
const trackId = req.params.id;
const { localPath, quality, fileSizeMb } = req.body;
if (!localPath || !quality || !fileSizeMb) {
return res
.status(400)
.json({ error: "localPath, quality, and fileSizeMb required" });
}
const cachedTrack = await prisma.cachedTrack.upsert({
where: {
userId_trackId_quality: {
userId,
trackId,
quality,
},
},
create: {
userId,
trackId,
localPath,
quality,
fileSizeMb: parseFloat(fileSizeMb),
},
update: {
localPath,
fileSizeMb: parseFloat(fileSizeMb),
lastAccessedAt: new Date(),
},
});
res.json(cachedTrack);
} catch (error) {
console.error("Complete track download error:", error);
res.status(500).json({ error: "Failed to complete download" });
}
});
// GET /offline/albums
router.get("/albums", async (req, res) => {
try {
const userId = req.session.userId!;
// Get all cached tracks grouped by album
const cachedTracks = await prisma.cachedTrack.findMany({
where: { userId },
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
},
},
});
// Group by album
const albumsMap = new Map();
for (const cached of cachedTracks) {
const albumId = cached.track.album.id;
if (!albumsMap.has(albumId)) {
albumsMap.set(albumId, {
album: cached.track.album,
tracks: [],
totalSizeMb: 0,
});
}
const albumData = albumsMap.get(albumId);
albumData.tracks.push({
...cached.track,
cachedPath: cached.localPath,
cachedQuality: cached.quality,
cachedSizeMb: cached.fileSizeMb,
});
albumData.totalSizeMb += cached.fileSizeMb;
}
const albums = Array.from(albumsMap.values()).map((data) => ({
...data.album,
cachedTracks: data.tracks,
totalSizeMb: data.totalSizeMb,
}));
res.json(albums);
} catch (error) {
console.error("Get cached albums error:", error);
res.status(500).json({ error: "Failed to get cached albums" });
}
});
// DELETE /offline/albums/:id
router.delete("/albums/:id", async (req, res) => {
try {
const userId = req.session.userId!;
const albumId = req.params.id;
// Get all cached tracks for this album
const cachedTracks = await prisma.cachedTrack.findMany({
where: {
userId,
track: {
albumId,
},
},
});
// Delete all cached tracks for this album
await prisma.cachedTrack.deleteMany({
where: {
userId,
track: {
albumId,
},
},
});
res.json({
message: "Album removed from cache",
deletedCount: cachedTracks.length,
});
} catch (error) {
console.error("Delete cached album error:", error);
res.status(500).json({ error: "Failed to delete cached album" });
}
});
// GET /offline/stats
router.get("/stats", async (req, res) => {
try {
const userId = req.session.userId!;
const [settings, cacheStats] = await Promise.all([
prisma.userSettings.findUnique({
where: { userId },
}),
prisma.cachedTrack.aggregate({
where: { userId },
_sum: { fileSizeMb: true },
_count: true,
}),
]);
const usedMb = cacheStats._sum.fileSizeMb || 0;
const maxMb = settings?.maxCacheSizeMb || 5120;
const trackCount = cacheStats._count || 0;
res.json({
usedMb,
maxMb,
availableMb: maxMb - usedMb,
percentUsed: (usedMb / maxMb) * 100,
trackCount,
});
} catch (error) {
console.error("Get cache stats error:", error);
res.status(500).json({ error: "Failed to get cache stats" });
}
});
export default router;
+475
View File
@@ -0,0 +1,475 @@
import { Router } from "express";
import { prisma } from "../utils/db";
import bcrypt from "bcrypt";
import { z } from "zod";
import axios from "axios";
import crypto from "crypto";
import { encryptField } from "../utils/systemSettings";
import { writeEnvFile } from "../utils/envWriter";
import { generateToken, requireAuth } from "../middleware/auth";
const router = Router();
// Validation schemas
const registerSchema = z.object({
username: z.string().min(3).max(50),
password: z.string().min(6),
});
const lidarrConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const audiobookshelfConfigSchema = z.object({
url: z.string().url().optional().or(z.literal("")),
apiKey: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const soulseekConfigSchema = z.object({
username: z.string().optional().or(z.literal("")),
password: z.string().optional().or(z.literal("")),
enabled: z.boolean(),
});
const enrichmentConfigSchema = z.object({
enabled: z.boolean(),
});
/**
* Generate a secure encryption key for settings encryption
* This is called automatically during first user registration
*/
async function ensureEncryptionKey(): Promise<void> {
// Check if encryption key already exists
if (
process.env.SETTINGS_ENCRYPTION_KEY &&
process.env.SETTINGS_ENCRYPTION_KEY !==
"default-encryption-key-change-me"
) {
console.log("[ONBOARDING] Encryption key already exists");
return;
}
// Generate a secure 32-byte encryption key
const encryptionKey = crypto.randomBytes(32).toString("base64");
console.log(
"[ONBOARDING] Generating encryption key for settings security..."
);
try {
// Write to .env file
await writeEnvFile({
SETTINGS_ENCRYPTION_KEY: encryptionKey,
});
// Update the process environment so it's available immediately
process.env.SETTINGS_ENCRYPTION_KEY = encryptionKey;
console.log("[ONBOARDING] Encryption key generated and saved to .env");
} catch (error) {
console.error("[ONBOARDING] ✗ Failed to save encryption key:", error);
throw new Error("Failed to generate encryption key");
}
}
/**
* POST /onboarding/register
* Step 1: Create user account - returns JWT token like regular login
*/
router.post("/register", async (req, res) => {
try {
console.log("[ONBOARDING] Register attempt for user:", req.body?.username);
const { username, password } = registerSchema.parse(req.body);
// Check if any user exists (first user becomes admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// If this is the first user, ensure encryption key is generated
if (isFirstUser) {
await ensureEncryptionKey();
}
// Check if username is taken
const existing = await prisma.user.findUnique({
where: { username },
});
if (existing) {
console.log("[ONBOARDING] Username already taken:", username);
return res.status(400).json({ error: "Username already taken" });
}
// Create user
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
role: isFirstUser ? "admin" : "user",
onboardingComplete: false,
},
});
// Create default user settings with optimal defaults
await prisma.userSettings.create({
data: {
userId: user.id,
playbackQuality: "original",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 10240, // 10GB
},
});
// Generate JWT token (same as login)
const token = generateToken({
id: user.id,
username: user.username,
role: user.role,
});
console.log("[ONBOARDING] User created successfully:", user.username);
res.json({
token,
user: {
id: user.id,
username: user.username,
role: user.role,
onboardingComplete: false,
},
});
} catch (err: any) {
if (err instanceof z.ZodError) {
console.error("[ONBOARDING] Validation error:", err.errors);
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Registration error:", err);
res.status(500).json({ error: "Failed to create account" });
}
});
/**
* POST /onboarding/lidarr
* Step 2a: Configure Lidarr integration
*/
router.post("/lidarr", requireAuth, async (req, res) => {
try {
const config = lidarrConfigSchema.parse(req.body);
// If not enabled, just save as disabled
if (!config.enabled) {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { lidarrEnabled: false },
});
}
return res.json({ success: true, tested: false });
}
// Test connection if enabled (non-blocking - save anyway)
let connectionTested = false;
if (config.url && config.apiKey) {
try {
const response = await axios.get(
`${config.url}/api/v1/system/status`,
{
headers: { "X-Api-Key": config.apiKey },
timeout: 5000,
}
);
if (response.status === 200) {
connectionTested = true;
console.log("Lidarr connection test successful");
}
} catch (error: any) {
console.warn(
" Lidarr connection test failed (saved anyway):",
error.message
);
// Don't block - just log the warning
}
}
// Save to system settings (even if connection test failed)
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
lidarrEnabled: config.enabled,
lidarrUrl: config.url || null,
lidarrApiKey: encryptField(config.apiKey),
},
update: {
lidarrEnabled: config.enabled,
lidarrUrl: config.url || null,
lidarrApiKey: encryptField(config.apiKey),
},
});
res.json({
success: true,
tested: connectionTested,
warning: connectionTested
? null
: "Connection test failed but settings saved. You can test again in Settings.",
});
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Lidarr config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/audiobookshelf
* Step 2b: Configure Audiobookshelf integration
*/
router.post("/audiobookshelf", requireAuth, async (req, res) => {
try {
const config = audiobookshelfConfigSchema.parse(req.body);
// If not enabled, just save as disabled
if (!config.enabled) {
const settings = await prisma.systemSettings.findFirst();
if (settings) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: { audiobookshelfEnabled: false },
});
}
return res.json({ success: true, tested: false });
}
// Test connection if enabled (non-blocking - save anyway)
let connectionTested = false;
if (config.url && config.apiKey) {
try {
const response = await axios.get(`${config.url}/api/me`, {
headers: { Authorization: `Bearer ${config.apiKey}` },
timeout: 5000,
});
if (response.status === 200) {
connectionTested = true;
console.log("Audiobookshelf connection test successful");
}
} catch (error: any) {
console.warn(
" Audiobookshelf connection test failed (saved anyway):",
error.message
);
// Don't block - just log the warning
}
}
// Save to system settings (even if connection test failed)
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
audiobookshelfEnabled: config.enabled,
audiobookshelfUrl: config.url || null,
audiobookshelfApiKey: encryptField(config.apiKey),
},
update: {
audiobookshelfEnabled: config.enabled,
audiobookshelfUrl: config.url || null,
audiobookshelfApiKey: encryptField(config.apiKey),
},
});
res.json({
success: true,
tested: connectionTested,
warning: connectionTested
? null
: "Connection test failed but settings saved. You can test again in Settings.",
});
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Audiobookshelf config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/soulseek
* Step 2c: Configure Soulseek integration (direct connection via slsk-client)
*/
router.post("/soulseek", requireAuth, async (req, res) => {
try {
const config = soulseekConfigSchema.parse(req.body);
// If not enabled, clear credentials
if (!config.enabled) {
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
soulseekUsername: null,
soulseekPassword: null,
},
update: {
soulseekUsername: null,
soulseekPassword: null,
},
});
return res.json({ success: true, tested: false });
}
// If enabled, require credentials
if (!config.username || !config.password) {
return res.status(400).json({
error: "Soulseek username and password are required",
});
}
// Save to system settings
await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
soulseekUsername: config.username,
soulseekPassword: encryptField(config.password),
},
update: {
soulseekUsername: config.username,
soulseekPassword: encryptField(config.password),
},
});
res.json({ success: true, tested: true });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Soulseek config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/enrichment
* Step 3: Configure metadata enrichment
*/
router.post("/enrichment", requireAuth, async (req, res) => {
try {
const config = enrichmentConfigSchema.parse(req.body);
// Update user settings
await prisma.user.update({
where: { id: req.user!.id },
data: {
enrichmentSettings: {
enabled: config.enabled,
lastRun: null,
},
},
});
res.json({ success: true });
} catch (err: any) {
if (err instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: err.errors });
}
console.error("Enrichment config error:", err);
res.status(500).json({ error: "Failed to save configuration" });
}
});
/**
* POST /onboarding/complete
* Final step: Mark onboarding as complete
*/
router.post("/complete", requireAuth, async (req, res) => {
try {
await prisma.user.update({
where: { id: req.user!.id },
data: { onboardingComplete: true },
});
console.log("[ONBOARDING] User completed onboarding:", req.user!.id);
res.json({ success: true });
} catch (err: any) {
console.error("Onboarding complete error:", err);
res.status(500).json({ error: "Failed to complete onboarding" });
}
});
/**
* GET /onboarding/status
* Check if user needs onboarding
*/
router.get("/status", async (req, res) => {
try {
// Check if any users exist in the system
const userCount = await prisma.user.count();
const hasAccount = userCount > 0;
// Check for JWT token in Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith("Bearer ")
? authHeader.substring(7)
: null;
// If no token, return whether any users exist
if (!token) {
return res.json({
needsOnboarding: !hasAccount,
hasAccount,
});
}
// Try to verify token and check onboarding status
try {
const jwt = require("jsonwebtoken");
const JWT_SECRET =
process.env.JWT_SECRET ||
"your-secret-key-change-in-production";
const decoded = jwt.verify(token, JWT_SECRET) as { userId: string };
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { onboardingComplete: true },
});
res.json({
needsOnboarding: !user?.onboardingComplete,
hasAccount: true,
});
} catch {
// Invalid token - return basic status
res.json({
needsOnboarding: !hasAccount,
hasAccount,
});
}
} catch (err: any) {
console.error("Onboarding status error:", err);
res.status(500).json({ error: "Failed to check status" });
}
});
export default router;
+149
View File
@@ -0,0 +1,149 @@
import express from "express";
import { prisma } from "../utils/db";
import { requireAuth } from "../middleware/auth";
const router = express.Router();
// Get current playback state for the authenticated user
router.get("/", requireAuth, async (req, res) => {
try {
const userId = req.user!.id;
const playbackState = await prisma.playbackState.findUnique({
where: { userId },
});
if (!playbackState) {
return res.json(null);
}
res.json(playbackState);
} catch (error) {
console.error("Get playback state error:", error);
res.status(500).json({ error: "Failed to get playback state" });
}
});
// Update current playback state for the authenticated user
router.post("/", requireAuth, async (req, res) => {
try {
const userId = req.user!.id;
const {
playbackType,
trackId,
audiobookId,
podcastId,
queue,
currentIndex,
isShuffle,
} = req.body;
// Validate required field
if (!playbackType) {
return res.status(400).json({ error: "playbackType is required" });
}
// Validate playback type
const validPlaybackTypes = ["track", "audiobook", "podcast"];
if (!validPlaybackTypes.includes(playbackType)) {
console.warn(`[PlaybackState] Invalid playbackType: ${playbackType}`);
return res.status(400).json({ error: "Invalid playbackType" });
}
// Limit queue size and sanitize queue items to prevent database issues
let safeQueue: any[] | null = null;
if (Array.isArray(queue) && queue.length > 0) {
// Only keep essential fields from each queue item to reduce JSON size
// Filter out any invalid items first
try {
safeQueue = queue
.slice(0, 100)
.filter((item: any) => item && item.id) // Must have at least an ID
.map((item: any) => ({
id: String(item.id || ""),
title: String(item.title || "Unknown").substring(0, 500), // Limit title length
duration: Number(item.duration) || 0,
artist: item.artist ? {
id: String(item.artist.id || ""),
name: String(item.artist.name || "Unknown").substring(0, 200),
} : null,
album: item.album ? {
id: String(item.album.id || ""),
title: String(item.album.title || "Unknown").substring(0, 500),
coverArt: item.album.coverArt ? String(item.album.coverArt).substring(0, 1000) : null,
} : null,
}));
// If sanitization removed all items, set to null
if (safeQueue.length === 0) {
safeQueue = null;
}
} catch (sanitizeError: any) {
console.error("[PlaybackState] Queue sanitization failed:", sanitizeError?.message);
safeQueue = null; // Fall back to null queue
}
}
const safeCurrentIndex = Math.min(
Math.max(0, currentIndex || 0),
safeQueue?.length ? safeQueue.length - 1 : 0
);
const playbackState = await prisma.playbackState.upsert({
where: { userId },
update: {
playbackType,
trackId: trackId || null,
audiobookId: audiobookId || null,
podcastId: podcastId || null,
queue: safeQueue,
currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false,
},
create: {
userId,
playbackType,
trackId: trackId || null,
audiobookId: audiobookId || null,
podcastId: podcastId || null,
queue: safeQueue,
currentIndex: safeCurrentIndex,
isShuffle: isShuffle || false,
},
});
res.json(playbackState);
} catch (error: any) {
console.error("[PlaybackState] Error saving state:", error?.message || error);
console.error("[PlaybackState] Full error:", JSON.stringify(error, Object.getOwnPropertyNames(error), 2));
if (error?.code) {
console.error("[PlaybackState] Error code:", error.code);
}
if (error?.meta) {
console.error("[PlaybackState] Prisma meta:", error.meta);
}
// Return more specific error for debugging
res.status(500).json({
error: "Internal server error",
details: error?.message || "Unknown error"
});
}
});
// Clear playback state (when user stops playback completely)
router.delete("/", requireAuth, async (req, res) => {
try {
const userId = req.user!.id;
await prisma.playbackState.delete({
where: { userId },
});
res.json({ success: true });
} catch (error) {
console.error("Delete playback state error:", error);
res.status(500).json({ error: "Failed to delete playback state" });
}
});
export default router;
+985
View File
@@ -0,0 +1,985 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { sessionLog } from "../utils/playlistLogger";
const router = Router();
router.use(requireAuthOrToken);
const createPlaylistSchema = z.object({
name: z.string().min(1).max(200),
isPublic: z.boolean().optional().default(false),
});
const addTrackSchema = z.object({
trackId: z.string(),
});
// GET /playlists
router.get("/", async (req, res) => {
try {
const userId = req.user.id;
// Get user's hidden playlists
const hiddenPlaylists = await prisma.hiddenPlaylist.findMany({
where: { userId },
select: { playlistId: true },
});
const hiddenPlaylistIds = new Set(
hiddenPlaylists.map((h) => h.playlistId)
);
const playlists = await prisma.playlist.findMany({
where: {
OR: [{ userId }, { isPublic: true }],
},
orderBy: { createdAt: "desc" },
include: {
user: {
select: {
username: true,
},
},
items: {
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
orderBy: { sort: "asc" },
},
},
});
const playlistsWithCounts = playlists.map((playlist) => ({
...playlist,
trackCount: playlist.items.length,
isOwner: playlist.userId === userId,
isHidden: hiddenPlaylistIds.has(playlist.id),
}));
// Debug: log shared playlists with user info
const sharedPlaylists = playlistsWithCounts.filter((p) => !p.isOwner);
if (sharedPlaylists.length > 0) {
console.log(
`[Playlists] Found ${sharedPlaylists.length} shared playlists for user ${userId}:`
);
sharedPlaylists.forEach((p) => {
console.log(
` - "${p.name}" by ${
p.user?.username || "UNKNOWN"
} (owner: ${p.userId})`
);
});
}
res.json(playlistsWithCounts);
} catch (error) {
console.error("Get playlists error:", error);
res.status(500).json({ error: "Failed to get playlists" });
}
});
// POST /playlists
router.post("/", async (req, res) => {
try {
const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body);
const playlist = await prisma.playlist.create({
data: {
userId,
name: data.name,
isPublic: data.isPublic,
},
});
res.json(playlist);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create playlist error:", error);
res.status(500).json({ error: "Failed to create playlist" });
}
});
// GET /playlists/:id
router.get("/:id", async (req, res) => {
try {
const userId = req.user.id;
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
include: {
user: {
select: {
username: true,
},
},
items: {
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
},
},
orderBy: { sort: "asc" },
},
pendingTracks: {
orderBy: { sort: "asc" },
},
},
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
// Check access permissions
if (!playlist.isPublic && playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Format playlist items
const formattedItems = playlist.items.map((item) => ({
...item,
type: "track" as const,
track: {
...item.track,
album: {
...item.track.album,
coverArt: item.track.album.coverUrl,
},
},
}));
// Format pending tracks
const formattedPending = playlist.pendingTracks.map((pending) => ({
id: pending.id,
type: "pending" as const,
sort: pending.sort,
pending: {
id: pending.id,
artist: pending.spotifyArtist,
title: pending.spotifyTitle,
album: pending.spotifyAlbum,
previewUrl: pending.deezerPreviewUrl,
},
}));
// Merge and sort by position
const mergedItems = [
...formattedItems.map((item) => ({ ...item, sort: item.sort })),
...formattedPending,
].sort((a, b) => a.sort - b.sort);
res.json({
...playlist,
isOwner: playlist.userId === userId,
trackCount: playlist.items.length,
pendingCount: playlist.pendingTracks.length,
items: formattedItems,
pendingTracks: formattedPending,
mergedItems,
});
} catch (error) {
console.error("Get playlist error:", error);
res.status(500).json({ error: "Failed to get playlist" });
}
});
// PUT /playlists/:id
router.put("/:id", async (req, res) => {
try {
const userId = req.user.id;
const data = createPlaylistSchema.parse(req.body);
// Check ownership
const existing = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!existing) {
return res.status(404).json({ error: "Playlist not found" });
}
if (existing.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
const playlist = await prisma.playlist.update({
where: { id: req.params.id },
data: {
name: data.name,
isPublic: data.isPublic,
},
});
res.json(playlist);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Update playlist error:", error);
res.status(500).json({ error: "Failed to update playlist" });
}
});
// POST /playlists/:id/hide - Hide any playlist from your view
router.post("/:id/hide", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Check playlist exists
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
// User must own the playlist OR it must be public (shared)
if (playlist.userId !== userId && !playlist.isPublic) {
return res.status(403).json({ error: "Access denied" });
}
// Create hidden record (upsert to handle re-hiding)
await prisma.hiddenPlaylist.upsert({
where: {
userId_playlistId: { userId, playlistId },
},
create: { userId, playlistId },
update: {},
});
res.json({ message: "Playlist hidden", isHidden: true });
} catch (error) {
console.error("Hide playlist error:", error);
res.status(500).json({ error: "Failed to hide playlist" });
}
});
// DELETE /playlists/:id/hide - Unhide a shared playlist
router.delete("/:id/hide", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Delete hidden record if exists
await prisma.hiddenPlaylist.deleteMany({
where: { userId, playlistId },
});
res.json({ message: "Playlist unhidden", isHidden: false });
} catch (error) {
console.error("Unhide playlist error:", error);
res.status(500).json({ error: "Failed to unhide playlist" });
}
});
// DELETE /playlists/:id
router.delete("/:id", async (req, res) => {
try {
const userId = req.user.id;
// Check ownership
const existing = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!existing) {
return res.status(404).json({ error: "Playlist not found" });
}
if (existing.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
await prisma.playlist.delete({
where: { id: req.params.id },
});
res.json({ message: "Playlist deleted" });
} catch (error) {
console.error("Delete playlist error:", error);
res.status(500).json({ error: "Failed to delete playlist" });
}
});
// POST /playlists/:id/items
router.post("/:id/items", async (req, res) => {
try {
const userId = req.user.id;
const parsedBody = addTrackSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
error: "Invalid request",
details: parsedBody.error.errors,
});
}
const { trackId } = parsedBody.data;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
include: {
items: {
orderBy: { sort: "desc" },
take: 1,
},
},
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Check if track exists
const track = await prisma.track.findUnique({
where: { id: trackId },
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
// Check if track already in playlist
const existing = await prisma.playlistItem.findUnique({
where: {
playlistId_trackId: {
playlistId: req.params.id,
trackId,
},
},
});
if (existing) {
return res.status(200).json({
message: "Track already in playlist",
duplicated: true,
item: existing,
});
}
// Get next sort position
const maxSort = playlist.items[0]?.sort || 0;
const item = await prisma.playlistItem.create({
data: {
playlistId: req.params.id,
trackId,
sort: maxSort + 1,
},
include: {
track: {
include: {
album: {
include: {
artist: true,
},
},
},
},
},
});
res.json(item);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Add track to playlist error:", error);
res.status(500).json({ error: "Failed to add track to playlist" });
}
});
// DELETE /playlists/:id/items/:trackId
router.delete("/:id/items/:trackId", async (req, res) => {
try {
const userId = req.user.id;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
await prisma.playlistItem.delete({
where: {
playlistId_trackId: {
playlistId: req.params.id,
trackId: req.params.trackId,
},
},
});
res.json({ message: "Track removed from playlist" });
} catch (error) {
console.error("Remove track from playlist error:", error);
res.status(500).json({ error: "Failed to remove track from playlist" });
}
});
// PUT /playlists/:id/items/reorder
router.put("/:id/items/reorder", async (req, res) => {
try {
const userId = req.user.id;
const { trackIds } = req.body; // Array of track IDs in new order
if (!Array.isArray(trackIds)) {
return res.status(400).json({ error: "trackIds must be an array" });
}
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: req.params.id },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Update sort order for each track
const updates = trackIds.map((trackId, index) =>
prisma.playlistItem.update({
where: {
playlistId_trackId: {
playlistId: req.params.id,
trackId,
},
},
data: { sort: index },
})
);
await prisma.$transaction(updates);
res.json({ message: "Playlist reordered" });
} catch (error) {
console.error("Reorder playlist error:", error);
res.status(500).json({ error: "Failed to reorder playlist" });
}
});
// ============================================
// Pending Tracks (from Spotify imports)
// ============================================
/**
* GET /playlists/:id/pending
* Get pending tracks for a playlist (tracks from Spotify that haven't been matched yet)
*/
router.get("/:id/pending", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Check ownership or public access
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId && !playlist.isPublic) {
return res.status(403).json({ error: "Access denied" });
}
const pendingTracks = await prisma.playlistPendingTrack.findMany({
where: { playlistId },
orderBy: { sort: "asc" },
});
res.json({
count: pendingTracks.length,
tracks: pendingTracks.map((t) => ({
id: t.id,
artist: t.spotifyArtist,
title: t.spotifyTitle,
album: t.spotifyAlbum,
position: t.sort,
previewUrl: t.deezerPreviewUrl,
})),
spotifyPlaylistId: playlist.spotifyPlaylistId,
});
} catch (error) {
console.error("Get pending tracks error:", error);
res.status(500).json({ error: "Failed to get pending tracks" });
}
});
/**
* DELETE /playlists/:id/pending/:trackId
* Remove a pending track (user decides they don't want to wait for it)
*/
router.delete("/:id/pending/:trackId", async (req, res) => {
try {
const userId = req.user.id;
const { id: playlistId, trackId: pendingTrackId } = req.params;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
await prisma.playlistPendingTrack.delete({
where: { id: pendingTrackId },
});
res.json({ message: "Pending track removed" });
} catch (error: any) {
if (error.code === "P2025") {
return res.status(404).json({ error: "Pending track not found" });
}
console.error("Delete pending track error:", error);
res.status(500).json({ error: "Failed to delete pending track" });
}
});
/**
* GET /playlists/:id/pending/:trackId/preview
* Get a fresh Deezer preview URL for a pending track (since they expire)
*/
router.get("/:id/pending/:trackId/preview", async (req, res) => {
try {
const { trackId: pendingTrackId } = req.params;
// Get the pending track
const pendingTrack = await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
return res.status(404).json({ error: "Pending track not found" });
}
// Fetch fresh Deezer preview URL
const { deezerService } = await import("../services/deezer");
const previewUrl = await deezerService.getTrackPreview(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (!previewUrl) {
return res
.status(404)
.json({ error: "No preview available on Deezer" });
}
// Update the stored preview URL for future use
await prisma.playlistPendingTrack.update({
where: { id: pendingTrackId },
data: { deezerPreviewUrl: previewUrl },
});
res.json({ previewUrl });
} catch (error: any) {
console.error("Get preview URL error:", error);
res.status(500).json({ error: "Failed to get preview URL" });
}
});
/**
* POST /playlists/:id/pending/:trackId/retry
* Retry downloading a failed/pending track from Soulseek
* Returns immediately and downloads in background
*/
router.post("/:id/pending/:trackId/retry", async (req, res) => {
try {
const userId = req.user.id;
const { id: playlistId, trackId: pendingTrackId } = req.params;
sessionLog(
"PENDING-RETRY",
`Request: userId=${userId} playlistId=${playlistId} pendingTrackId=${pendingTrackId}`
);
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
sessionLog(
"PENDING-RETRY",
`Playlist not found: ${playlistId}`,
"WARN"
);
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
sessionLog(
"PENDING-RETRY",
`Access denied: playlistId=${playlistId} userId=${userId}`,
"WARN"
);
return res.status(403).json({ error: "Access denied" });
}
// Get the pending track
const pendingTrack = await prisma.playlistPendingTrack.findUnique({
where: { id: pendingTrackId },
});
if (!pendingTrack) {
sessionLog(
"PENDING-RETRY",
`Pending track not found: ${pendingTrackId}`,
"WARN"
);
return res.status(404).json({ error: "Pending track not found" });
}
sessionLog(
"PENDING-RETRY",
`Pending track: artist="${pendingTrack.spotifyArtist}" title="${pendingTrack.spotifyTitle}" album="${pendingTrack.spotifyAlbum}"`
);
// Create a DownloadJob so this retry appears in Activity (active/history)
const retryTargetId =
pendingTrack.albumMbid ||
pendingTrack.artistMbid ||
`pendingTrack:${pendingTrack.id}`;
const downloadJob = await prisma.downloadJob.create({
data: {
userId,
subject: `${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`,
type: "track",
targetMbid: retryTargetId,
artistMbid: pendingTrack.artistMbid,
status: "processing",
attempts: 1,
startedAt: new Date(),
metadata: {
downloadType: "pending-track-retry",
source: "soulseek",
playlistId,
pendingTrackId,
spotifyArtist: pendingTrack.spotifyArtist,
spotifyTitle: pendingTrack.spotifyTitle,
spotifyAlbum: pendingTrack.spotifyAlbum,
albumMbid: pendingTrack.albumMbid,
},
},
});
sessionLog(
"PENDING-RETRY",
`Created download job: downloadJobId=${downloadJob.id} target=${retryTargetId}`
);
// Import soulseek service and try to download
const { soulseekService } = await import("../services/soulseek");
const { getSystemSettings } = await import("../utils/systemSettings");
const settings = await getSystemSettings();
if (!settings?.musicPath) {
sessionLog("PENDING-RETRY", `Music path not configured`, "WARN");
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: "Music path not configured",
completedAt: new Date(),
},
});
return res.status(400).json({ error: "Music path not configured" });
}
if (!settings?.soulseekUsername || !settings?.soulseekPassword) {
sessionLog(
"PENDING-RETRY",
`Soulseek credentials not configured`,
"WARN"
);
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: "Soulseek credentials not configured",
completedAt: new Date(),
},
});
return res
.status(400)
.json({ error: "Soulseek credentials not configured" });
}
// Use a better album name if possible - extract from stored title or use artist name
const albumName =
pendingTrack.spotifyAlbum !== "Unknown Album"
? pendingTrack.spotifyAlbum
: pendingTrack.spotifyArtist; // Use artist as fallback folder name
console.log(
`[Retry] Starting download for: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
);
sessionLog(
"PENDING-RETRY",
`Search: ${pendingTrack.spotifyArtist} - ${pendingTrack.spotifyTitle}`
);
// First do a quick search to see if track is available (15s timeout)
// This way we can tell the user immediately if it's not found
const searchResult = await soulseekService.searchTrack(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle
);
if (!searchResult.found || searchResult.allMatches.length === 0) {
console.log(`[Retry] ✗ No results found on Soulseek`);
sessionLog("PENDING-RETRY", `No results found on Soulseek`, "INFO");
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: "No matching files found",
completedAt: new Date(),
},
});
return res.status(200).json({
success: false,
message: "Track not found on Soulseek",
error: "No matching files found",
});
}
console.log(
`[Retry] ✓ Found ${searchResult.allMatches.length} results, starting download in background`
);
sessionLog(
"PENDING-RETRY",
`Found ${searchResult.allMatches.length} candidate(s); starting background download`
);
// Return immediately - download happens in background
res.json({
success: true,
message: "Download started",
note: `Found ${searchResult.allMatches.length} sources. Downloading... Track will appear after scan.`,
downloadJobId: downloadJob.id,
});
// Start download in background (don't await)
soulseekService
.downloadBestMatch(
pendingTrack.spotifyArtist,
pendingTrack.spotifyTitle,
albumName,
searchResult.allMatches,
settings.musicPath
)
.then(async (result) => {
if (result.success) {
console.log(
`[Retry] ✓ Download complete: ${result.filePath}`
);
sessionLog(
"PENDING-RETRY",
`Download complete: filePath=${result.filePath}`
);
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "completed",
completedAt: new Date(),
metadata: {
...(downloadJob.metadata as any),
filePath: result.filePath,
},
},
});
// Trigger a library scan to add the track and reconcile pending
try {
const { scanQueue } = await import("../workers/queues");
const scanJob = await scanQueue.add(
"scan",
{
userId,
source: "retry-pending-track",
albumMbid: pendingTrack.albumMbid || undefined,
artistMbid:
pendingTrack.artistMbid || undefined,
},
{
priority: 1, // High priority
removeOnComplete: true,
}
);
console.log(
`[Retry] Queued library scan to reconcile pending tracks`
);
sessionLog(
"PENDING-RETRY",
`Queued library scan (bullJobId=${
scanJob.id ?? "unknown"
})`
);
} catch (scanError) {
console.error(
`[Retry] Failed to queue scan:`,
scanError
);
sessionLog(
"PENDING-RETRY",
`Failed to queue scan: ${
(scanError as any)?.message || scanError
}`,
"ERROR"
);
}
} else {
console.log(`[Retry] ✗ Download failed: ${result.error}`);
sessionLog(
"PENDING-RETRY",
`Download failed: ${result.error || "unknown error"}`,
"WARN"
);
await prisma.downloadJob.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: result.error || "Download failed",
completedAt: new Date(),
},
});
}
})
.catch((error) => {
console.error(`[Retry] Download error:`, error);
sessionLog(
"PENDING-RETRY",
`Download exception: ${error?.message || error}`,
"ERROR"
);
prisma.downloadJob
.update({
where: { id: downloadJob.id },
data: {
status: "failed",
error: error?.message || "Download exception",
completedAt: new Date(),
},
})
.catch(() => undefined);
});
} catch (error: any) {
console.error("Retry pending track error:", error);
sessionLog(
"PENDING-RETRY",
`Handler error: ${error?.message || error}`,
"ERROR"
);
res.status(500).json({
error: "Failed to retry download",
details: error.message,
});
}
});
/**
* POST /playlists/:id/pending/reconcile
* Manually trigger reconciliation for a specific playlist
*/
router.post("/:id/pending/reconcile", async (req, res) => {
try {
const userId = req.user.id;
const playlistId = req.params.id;
// Check ownership
const playlist = await prisma.playlist.findUnique({
where: { id: playlistId },
});
if (!playlist) {
return res.status(404).json({ error: "Playlist not found" });
}
if (playlist.userId !== userId) {
return res.status(403).json({ error: "Access denied" });
}
// Import and run reconciliation
const { spotifyImportService } = await import(
"../services/spotifyImport"
);
const result = await spotifyImportService.reconcilePendingTracks();
res.json({
message: "Reconciliation complete",
tracksAdded: result.tracksAdded,
playlistsUpdated: result.playlistsUpdated,
});
} catch (error) {
console.error("Reconcile pending tracks error:", error);
res.status(500).json({ error: "Failed to reconcile pending tracks" });
}
});
export default router;
+84
View File
@@ -0,0 +1,84 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const playSchema = z.object({
trackId: z.string(),
});
// POST /plays
router.post("/", async (req, res) => {
try {
const userId = req.session.userId!;
const { trackId } = playSchema.parse(req.body);
// Verify track exists
const track = await prisma.track.findUnique({
where: { id: trackId },
});
if (!track) {
return res.status(404).json({ error: "Track not found" });
}
const play = await prisma.play.create({
data: {
userId,
trackId,
},
});
res.json(play);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid request", details: error.errors });
}
console.error("Create play error:", error);
res.status(500).json({ error: "Failed to log play" });
}
});
// GET /plays (recent plays for user)
router.get("/", async (req, res) => {
try {
const userId = req.session.userId!;
const { limit = "50" } = req.query;
const plays = await prisma.play.findMany({
where: { userId },
orderBy: { playedAt: "desc" },
take: parseInt(limit as string, 10),
include: {
track: {
include: {
album: {
include: {
artist: {
select: {
id: true,
name: true,
mbid: true,
},
},
},
},
},
},
},
});
res.json(plays);
} catch (error) {
console.error("Get plays error:", error);
res.status(500).json({ error: "Failed to get plays" });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
import { Router } from "express";
import { requireAuth, requireAuthOrToken } from "../middleware/auth";
import { prisma } from "../utils/db";
import { lastFmService } from "../services/lastfm";
const router = Router();
router.use(requireAuthOrToken);
// GET /recommendations/for-you?limit=10
router.get("/for-you", async (req, res) => {
try {
const { limit = "10" } = req.query;
const userId = req.user!.id;
const limitNum = parseInt(limit as string, 10);
// Get user's most played artists
const recentPlays = await prisma.play.findMany({
where: { userId },
orderBy: { playedAt: "desc" },
take: 50,
include: {
track: {
include: {
album: {
include: {
artist: true,
},
},
},
},
},
});
// Count plays per artist
const artistPlayCounts = new Map<
string,
{ artist: any; count: number }
>();
for (const play of recentPlays) {
const artist = play.track.album.artist;
const existing = artistPlayCounts.get(artist.id);
if (existing) {
existing.count++;
} else {
artistPlayCounts.set(artist.id, { artist, count: 1 });
}
}
// Sort by play count and get top 3 seed artists
const topArtists = Array.from(artistPlayCounts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 3);
if (topArtists.length === 0) {
// No listening history, return empty recommendations
return res.json({ artists: [] });
}
// Get similar artists for each top artist
const allSimilarArtists = await Promise.all(
topArtists.map(async ({ artist }) => {
const similar = await prisma.similarArtist.findMany({
where: { fromArtistId: artist.id },
orderBy: { weight: "desc" },
take: 10,
include: {
toArtist: {
select: {
id: true,
mbid: true,
name: true,
heroUrl: true,
},
},
},
});
return similar.map((s) => s.toArtist);
})
);
// Flatten and deduplicate
const recommendedArtists = Array.from(
new Map(
allSimilarArtists.flat().map((artist) => [artist.id, artist])
).values()
);
// Filter out artists user already owns (from native library)
const ownedArtists = await prisma.ownedAlbum.findMany({
select: { artistId: true },
distinct: ["artistId"],
});
const ownedArtistIds = new Set(ownedArtists.map((a) => a.artistId));
console.log(
`Filtering recommendations: ${ownedArtistIds.size} owned artists to exclude`
);
const newArtists = recommendedArtists.filter(
(artist) => !ownedArtistIds.has(artist.id)
);
// Get album counts for recommended artists (from enriched discography)
const recommendedArtistIds = newArtists
.slice(0, limitNum)
.map((a) => a.id);
const albumCounts = await prisma.album.groupBy({
by: ["artistId"],
where: { artistId: { in: recommendedArtistIds } },
_count: { rgMbid: true },
});
const albumCountMap = new Map(
albumCounts.map((ac) => [ac.artistId, ac._count.rgMbid])
);
// ========== CACHE-ONLY IMAGE LOOKUP FOR RECOMMENDATIONS ==========
// Only use cached data (DB heroUrl or Redis cache) - no API calls during page loads
// Background enrichment worker will populate cache over time
const { redisClient } = await import("../utils/redis");
// Get all cached images in a single Redis call for efficiency
const artistsToCheck = newArtists.slice(0, limitNum);
const cacheKeys = artistsToCheck
.filter(a => !a.heroUrl)
.map(a => `hero:${a.id}`);
let cachedImages: (string | null)[] = [];
if (cacheKeys.length > 0) {
try {
cachedImages = await redisClient.mGet(cacheKeys);
} catch (err) {
// Redis errors are non-critical
}
}
// Build a map from cache results
const cachedImageMap = new Map<string, string>();
let cacheIndex = 0;
for (const artist of artistsToCheck) {
if (!artist.heroUrl) {
const cached = cachedImages[cacheIndex];
if (cached && cached !== "NOT_FOUND") {
cachedImageMap.set(artist.id, cached);
}
cacheIndex++;
}
}
const artistsWithMetadata = artistsToCheck.map((artist) => {
// Use DB heroUrl first, then Redis cache, otherwise null
const coverArt = artist.heroUrl || cachedImageMap.get(artist.id) || null;
return {
...artist,
coverArt,
albumCount: albumCountMap.get(artist.id) || 0,
};
});
console.log(
`Recommendations: Found ${artistsWithMetadata.length} new artists`
);
artistsWithMetadata.forEach((a) => {
console.log(
` ${a.name}: coverArt=${a.coverArt ? "YES" : "NO"}, albums=${
a.albumCount
}`
);
});
res.json({ artists: artistsWithMetadata });
} catch (error) {
console.error("Get recommendations for you error:", error);
res.status(500).json({ error: "Failed to get recommendations" });
}
});
// GET /recommendations?seedArtistId=
router.get("/", async (req, res) => {
try {
const { seedArtistId } = req.query;
if (!seedArtistId) {
return res.status(400).json({ error: "seedArtistId required" });
}
// Get seed artist
const seedArtist = await prisma.artist.findUnique({
where: { id: seedArtistId as string },
});
if (!seedArtist) {
return res.status(404).json({ error: "Artist not found" });
}
// Get similar artists from database
const similarArtists = await prisma.similarArtist.findMany({
where: { fromArtistId: seedArtistId as string },
orderBy: { weight: "desc" },
take: 20,
});
// Fetch full artist details for each similar artist
const recommendations = await Promise.all(
similarArtists.map(async (similar) => {
const artist = await prisma.artist.findUnique({
where: { id: similar.toArtistId },
});
const albums = await prisma.album.findMany({
where: { artistId: similar.toArtistId },
orderBy: { year: "desc" },
take: 3,
});
const ownedAlbums = await prisma.ownedAlbum.findMany({
where: { artistId: similar.toArtistId },
});
const ownedRgMbids = new Set(ownedAlbums.map((o) => o.rgMbid));
return {
artist: {
id: artist?.id,
mbid: artist?.mbid,
name: artist?.name,
heroUrl: artist?.heroUrl,
},
similarity: similar.weight,
topAlbums: albums.map((album) => ({
...album,
owned: ownedRgMbids.has(album.rgMbid),
})),
};
})
);
res.json({
seedArtist: {
id: seedArtist.id,
name: seedArtist.name,
},
recommendations,
});
} catch (error) {
console.error("Get recommendations error:", error);
res.status(500).json({ error: "Failed to get recommendations" });
}
});
// GET /recommendations/albums?seedAlbumId=
router.get("/albums", async (req, res) => {
try {
const { seedAlbumId } = req.query;
if (!seedAlbumId) {
return res.status(400).json({ error: "seedAlbumId required" });
}
// Get seed album
const seedAlbum = await prisma.album.findUnique({
where: { id: seedAlbumId as string },
include: {
artist: true,
tracks: {
include: {
trackGenres: {
include: {
genre: true,
},
},
},
},
},
});
if (!seedAlbum) {
return res.status(404).json({ error: "Album not found" });
}
// Get genre tags from the album's tracks
const genreTags = Array.from(
new Set(
seedAlbum.tracks.flatMap((track) =>
track.trackGenres.map((tg) => tg.genre.name)
)
)
);
// Strategy 1: Get albums from similar artists
const similarArtists = await prisma.similarArtist.findMany({
where: { fromArtistId: seedAlbum.artistId },
orderBy: { weight: "desc" },
take: 10,
});
const similarArtistAlbums = await prisma.album.findMany({
where: {
artistId: { in: similarArtists.map((sa) => sa.toArtistId) },
id: { not: seedAlbumId as string }, // Exclude seed album
},
include: {
artist: true,
},
orderBy: { year: "desc" },
take: 15,
});
// Strategy 2: Get albums with matching genres
let genreMatchAlbums: any[] = [];
if (genreTags.length > 0) {
genreMatchAlbums = await prisma.album.findMany({
where: {
id: { not: seedAlbumId as string },
tracks: {
some: {
trackGenres: {
some: {
genre: {
name: { in: genreTags },
},
},
},
},
},
},
include: {
artist: true,
},
take: 10,
});
}
// Combine and deduplicate
const allAlbums = [...similarArtistAlbums, ...genreMatchAlbums];
const uniqueAlbums = Array.from(
new Map(allAlbums.map((album) => [album.id, album])).values()
);
// Check ownership
const recommendations = await Promise.all(
uniqueAlbums.slice(0, 20).map(async (album) => {
const ownedAlbums = await prisma.ownedAlbum.findMany({
where: { artistId: album.artistId },
});
const ownedRgMbids = new Set(ownedAlbums.map((o) => o.rgMbid));
return {
...album,
owned: ownedRgMbids.has(album.rgMbid),
};
})
);
res.json({
seedAlbum: {
id: seedAlbum.id,
title: seedAlbum.title,
artist: seedAlbum.artist.name,
},
recommendations,
});
} catch (error) {
console.error("Get album recommendations error:", error);
res.status(500).json({
error: "Failed to get album recommendations",
});
}
});
// GET /recommendations/tracks?seedTrackId=
router.get("/tracks", async (req, res) => {
try {
const { seedTrackId } = req.query;
if (!seedTrackId) {
return res.status(400).json({ error: "seedTrackId required" });
}
// Get seed track
const seedTrack = await prisma.track.findUnique({
where: { id: seedTrackId as string },
include: {
album: {
include: {
artist: true,
},
},
},
});
if (!seedTrack) {
return res.status(404).json({ error: "Track not found" });
}
// Use Last.fm to get similar tracks
const similarTracksFromLastFm = await lastFmService.getSimilarTracks(
seedTrack.album.artist.name,
seedTrack.title,
20
);
// Try to match similar tracks in our library
const recommendations = [];
for (const lfmTrack of similarTracksFromLastFm) {
const matchedTracks = await prisma.track.findMany({
where: {
title: {
contains: lfmTrack.name,
mode: "insensitive",
},
album: {
artist: {
name: {
contains: lfmTrack.artist?.name || "",
mode: "insensitive",
},
},
},
},
include: {
album: {
include: {
artist: true,
},
},
},
take: 1,
});
if (matchedTracks.length > 0) {
recommendations.push({
...matchedTracks[0],
inLibrary: true,
similarity: lfmTrack.match || 0,
});
} else {
// Include Last.fm suggestion even if not in library
recommendations.push({
title: lfmTrack.name,
artist: lfmTrack.artist?.name || "Unknown",
inLibrary: false,
similarity: lfmTrack.match || 0,
lastFmUrl: lfmTrack.url,
});
}
}
res.json({
seedTrack: {
id: seedTrack.id,
title: seedTrack.title,
artist: seedTrack.album.artist.name,
album: seedTrack.album.title,
},
recommendations,
});
} catch (error) {
console.error("Get track recommendations error:", error);
res.status(500).json({
error: "Failed to get track recommendations",
});
}
});
export default router;
+259
View File
@@ -0,0 +1,259 @@
/**
* Release Radar API
*
* Provides upcoming and recent releases from:
* 1. Lidarr monitored artists (via calendar API)
* 2. Similar artists from user's library (Last.fm similar artists)
*/
import { Router } from "express";
import { lidarrService, CalendarRelease } from "../services/lidarr";
import { prisma } from "../utils/db";
const router = Router();
interface ReleaseRadarResponse {
upcoming: ReleaseItem[];
recent: ReleaseItem[];
monitoredArtistCount: number;
similarArtistCount: number;
}
interface ReleaseItem {
id: number | string;
title: string;
artistName: string;
artistMbid?: string;
albumMbid: string;
releaseDate: string;
coverUrl: string | null;
source: 'lidarr' | 'similar';
status: 'upcoming' | 'released' | 'available';
inLibrary: boolean;
canDownload: boolean;
}
/**
* GET /releases/radar
*
* Get upcoming and recent releases for the user's monitored artists
* and their similar artists.
*/
router.get("/radar", async (req, res) => {
try {
const now = new Date();
const daysBack = parseInt(req.query.daysBack as string) || 30;
const daysAhead = parseInt(req.query.daysAhead as string) || 90;
// Calculate date range
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - daysBack);
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + daysAhead);
console.log(`[Releases] Fetching radar: ${daysBack} days back, ${daysAhead} days ahead`);
// 1. Get releases from Lidarr calendar (monitored artists)
const lidarrReleases = await lidarrService.getCalendar(startDate, endDate);
// 2. Get monitored artists from Lidarr
const monitoredArtists = await lidarrService.getMonitoredArtists();
const monitoredMbids = new Set(monitoredArtists.map(a => a.mbid));
// 3. Get similar artists from user's library that aren't monitored
const similarArtists = await prisma.similarArtist.findMany({
where: {
// Source artist is in the library (has albums)
fromArtist: {
albums: { some: {} }
},
// Target artist is NOT in library (no albums)
toArtist: {
albums: { none: {} }
}
},
select: {
toArtist: {
select: {
id: true,
name: true,
mbid: true,
}
},
weight: true,
},
orderBy: { weight: 'desc' },
take: 50, // Top 50 similar artists
});
// Filter out any that are already monitored in Lidarr
const unmonitoredSimilar = similarArtists.filter(
sa => sa.toArtist.mbid && !monitoredMbids.has(sa.toArtist.mbid)
);
console.log(`[Releases] Found ${lidarrReleases.length} Lidarr releases`);
console.log(`[Releases] Found ${unmonitoredSimilar.length} unmonitored similar artists`);
// 4. Get albums in library to check what user already has
const libraryAlbums = await prisma.album.findMany({
select: {
rgMbid: true,
}
});
const libraryAlbumMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
// 5. Transform Lidarr releases
const releases: ReleaseItem[] = lidarrReleases.map(release => {
const releaseTime = new Date(release.releaseDate).getTime();
const isUpcoming = releaseTime > now.getTime();
const inLibrary = release.hasFile || libraryAlbumMbids.has(release.albumMbid);
return {
id: release.id,
title: release.title,
artistName: release.artistName,
artistMbid: release.artistMbid,
albumMbid: release.albumMbid,
releaseDate: release.releaseDate,
coverUrl: release.coverUrl,
source: 'lidarr' as const,
status: isUpcoming ? 'upcoming' : (inLibrary ? 'available' : 'released'),
inLibrary,
canDownload: !inLibrary && !isUpcoming,
};
});
// 6. Split into upcoming and recent
const upcoming = releases
.filter(r => r.status === 'upcoming')
.sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime());
const recent = releases
.filter(r => r.status !== 'upcoming')
.sort((a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime());
const response: ReleaseRadarResponse = {
upcoming,
recent,
monitoredArtistCount: monitoredArtists.length,
similarArtistCount: unmonitoredSimilar.length,
};
res.json(response);
} catch (error: any) {
console.error("[Releases] Radar error:", error.message);
res.status(500).json({ error: "Failed to fetch release radar" });
}
});
/**
* GET /releases/upcoming
*
* Get only upcoming releases (next X days)
*/
router.get("/upcoming", async (req, res) => {
try {
const daysAhead = parseInt(req.query.days as string) || 90;
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + daysAhead);
const releases = await lidarrService.getCalendar(now, endDate);
// Sort by release date (soonest first)
const sorted = releases.sort((a, b) =>
new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime()
);
res.json({
releases: sorted,
count: sorted.length,
daysAhead,
});
} catch (error: any) {
console.error("[Releases] Upcoming error:", error.message);
res.status(500).json({ error: "Failed to fetch upcoming releases" });
}
});
/**
* GET /releases/recent
*
* Get recently released albums (last X days) that user might want to download
*/
router.get("/recent", async (req, res) => {
try {
const daysBack = parseInt(req.query.days as string) || 30;
const now = new Date();
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - daysBack);
const releases = await lidarrService.getCalendar(startDate, now);
// Get library albums to mark what's already downloaded
const libraryAlbums = await prisma.album.findMany({
where: { rgMbid: { not: null } },
select: { rgMbid: true }
});
const libraryMbids = new Set(libraryAlbums.map(a => a.rgMbid).filter(Boolean));
// Filter to releases not in library and sort (newest first)
const notInLibrary = releases
.filter(r => !r.hasFile && !libraryMbids.has(r.albumMbid))
.sort((a, b) =>
new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
);
res.json({
releases: notInLibrary,
count: notInLibrary.length,
daysBack,
inLibraryCount: releases.length - notInLibrary.length,
});
} catch (error: any) {
console.error("[Releases] Recent error:", error.message);
res.status(500).json({ error: "Failed to fetch recent releases" });
}
});
/**
* POST /releases/download/:albumMbid
*
* Download a release from the radar
*/
router.post("/download/:albumMbid", async (req, res) => {
try {
const { albumMbid } = req.params;
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ error: "Authentication required" });
}
console.log(`[Releases] Download requested for album: ${albumMbid}`);
// Use Lidarr to download the album
const result = await lidarrService.downloadAlbum(albumMbid);
if (result) {
res.json({
success: true,
message: "Download started",
albumId: result.id
});
} else {
res.status(404).json({
error: "Album not found in Lidarr or download failed"
});
}
} catch (error: any) {
console.error("[Releases] Download error:", error.message);
res.status(500).json({ error: "Failed to start download" });
}
});
export default router;
+432
View File
@@ -0,0 +1,432 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { audiobookshelfService } from "../services/audiobookshelf";
import { lastFmService } from "../services/lastfm";
import { searchService } from "../services/search";
import axios from "axios";
import { redisClient } from "../utils/redis";
const router = Router();
router.use(requireAuth);
/**
* @openapi
* /search:
* get:
* summary: Search across your music library
* description: Search for artists, albums, tracks, audiobooks, and podcasts in your library using PostgreSQL full-text search
* tags: [Search]
* security:
* - sessionAuth: []
* - apiKeyAuth: []
* parameters:
* - in: query
* name: q
* schema:
* type: string
* required: true
* description: Search query
* example: "radiohead"
* - in: query
* name: type
* schema:
* type: string
* enum: [all, artists, albums, tracks, audiobooks, podcasts]
* description: Type of content to search
* default: all
* - in: query
* name: genre
* schema:
* type: string
* description: Filter tracks by genre
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 100
* description: Maximum number of results per type
* default: 20
* responses:
* 200:
* description: Search results
* content:
* application/json:
* schema:
* type: object
* properties:
* artists:
* type: array
* items:
* $ref: '#/components/schemas/Artist'
* albums:
* type: array
* items:
* $ref: '#/components/schemas/Album'
* tracks:
* type: array
* items:
* $ref: '#/components/schemas/Track'
* audiobooks:
* type: array
* items:
* type: object
* podcasts:
* type: array
* items:
* type: object
* 401:
* description: Not authenticated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
router.get("/", async (req, res) => {
try {
const { q = "", type = "all", genre, limit = "20" } = req.query;
const query = (q as string).trim();
const searchLimit = Math.min(parseInt(limit as string, 10), 100);
if (!query) {
return res.json({
artists: [],
albums: [],
tracks: [],
audiobooks: [],
podcasts: [],
});
}
// Check cache for library search (short TTL since library can change)
const cacheKey = `search:library:${type}:${genre || ""}:${query}:${searchLimit}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[SEARCH] Cache hit for query="${query}"`);
return res.json(JSON.parse(cached));
}
} catch (err) {
// Redis errors are non-critical
}
const results: any = {
artists: [],
albums: [],
tracks: [],
audiobooks: [],
podcasts: [],
};
// Search artists using full-text search (only show artists with actual albums in library)
if (type === "all" || type === "artists") {
const artistResults = await searchService.searchArtists({
query,
limit: searchLimit,
});
// Filter to only include artists with albums
const artistIds = artistResults.map((a) => a.id);
const artistsWithAlbums = await prisma.artist.findMany({
where: {
id: { in: artistIds },
albums: {
some: {},
},
},
select: {
id: true,
mbid: true,
name: true,
heroUrl: true,
summary: true,
},
});
// Preserve rank order from full-text search
const rankMap = new Map(artistResults.map((a) => [a.id, a.rank]));
results.artists = artistsWithAlbums.sort((a, b) => {
const rankA = rankMap.get(a.id) || 0;
const rankB = rankMap.get(b.id) || 0;
return rankB - rankA; // Sort by rank DESC
});
}
// Search albums using full-text search
if (type === "all" || type === "albums") {
const albumResults = await searchService.searchAlbums({
query,
limit: searchLimit,
});
results.albums = albumResults.map((album) => ({
id: album.id,
title: album.title,
artistId: album.artistId,
year: album.year,
coverUrl: album.coverUrl,
artist: {
id: album.artistId,
name: album.artistName,
mbid: "", // Not included in search result
},
}));
}
// Search tracks using full-text search
if (type === "all" || type === "tracks") {
const trackResults = await searchService.searchTracks({
query,
limit: searchLimit,
});
// If genre filter is applied, filter the results
if (genre) {
const trackIds = trackResults.map((t) => t.id);
const tracksWithGenre = await prisma.track.findMany({
where: {
id: { in: trackIds },
trackGenres: {
some: {
genre: {
name: {
equals: genre as string,
mode: "insensitive",
},
},
},
},
},
select: { id: true },
});
const genreTrackIds = new Set(tracksWithGenre.map((t) => t.id));
results.tracks = trackResults
.filter((t) => genreTrackIds.has(t.id))
.map((track) => ({
id: track.id,
title: track.title,
albumId: track.albumId,
duration: track.duration,
trackNo: 0, // Not included in search result
album: {
id: track.albumId,
title: track.albumTitle,
artistId: track.artistId,
coverUrl: null, // Not included in search result
artist: {
id: track.artistId,
name: track.artistName,
mbid: "", // Not included in search result
},
},
}));
} else {
results.tracks = trackResults.map((track) => ({
id: track.id,
title: track.title,
albumId: track.albumId,
duration: track.duration,
trackNo: 0, // Not included in search result
album: {
id: track.albumId,
title: track.albumTitle,
artistId: track.artistId,
coverUrl: null, // Not included in search result
artist: {
id: track.artistId,
name: track.artistName,
mbid: "", // Not included in search result
},
},
}));
}
}
// Search audiobooks
if (type === "all" || type === "audiobooks") {
try {
const audiobooks = await audiobookshelfService.searchAudiobooks(
query
);
results.audiobooks = audiobooks.slice(0, searchLimit);
} catch (error) {
console.error("Audiobook search error:", error);
results.audiobooks = [];
}
}
// Search podcasts (search through owned podcasts)
if (type === "all" || type === "podcasts") {
try {
const allPodcasts =
await audiobookshelfService.getAllPodcasts();
results.podcasts = allPodcasts
.filter(
(p) =>
p.media?.metadata?.title
?.toLowerCase()
.includes(query.toLowerCase()) ||
p.media?.metadata?.author
?.toLowerCase()
.includes(query.toLowerCase())
)
.slice(0, searchLimit);
} catch (error) {
console.error("Podcast search error:", error);
results.podcasts = [];
}
}
// Cache search results for 2 minutes (library can change)
try {
await redisClient.setEx(cacheKey, 120, JSON.stringify(results));
} catch (err) {
// Redis errors are non-critical
}
res.json(results);
} catch (error) {
console.error("Search error:", error);
res.status(500).json({ error: "Search failed" });
}
});
// GET /search/genres
router.get("/genres", async (req, res) => {
try {
const genres = await prisma.genre.findMany({
orderBy: { name: "asc" },
include: {
_count: {
select: { trackGenres: true },
},
},
});
res.json(
genres.map((g) => ({
id: g.id,
name: g.name,
trackCount: g._count.trackGenres,
}))
);
} catch (error) {
console.error("Get genres error:", error);
res.status(500).json({ error: "Failed to get genres" });
}
});
/**
* GET /search/discover?q=query&type=music|podcasts
* Search for NEW content to discover (not in your library)
*/
router.get("/discover", async (req, res) => {
try {
const { q = "", type = "music", limit = "20" } = req.query;
const query = (q as string).trim();
const searchLimit = Math.min(parseInt(limit as string, 10), 50);
if (!query) {
return res.json({ results: [] });
}
const cacheKey = `search:discover:${type}:${query}:${searchLimit}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(
`[SEARCH DISCOVER] Cache hit for query="${query}" type=${type}`
);
return res.json(JSON.parse(cached));
}
} catch (err) {
console.warn("[SEARCH DISCOVER] Redis read error:", err);
}
const results: any[] = [];
if (type === "music" || type === "all") {
// Search Last.fm for artists AND tracks
try {
// Search for artists
const lastfmArtistResults = await lastFmService.searchArtists(
query,
searchLimit
);
console.log(
`[SEARCH ENDPOINT] Found ${lastfmArtistResults.length} artist results`
);
results.push(...lastfmArtistResults);
// Search for tracks (songs)
const lastfmTrackResults = await lastFmService.searchTracks(
query,
searchLimit
);
console.log(
`[SEARCH ENDPOINT] Found ${lastfmTrackResults.length} track results`
);
results.push(...lastfmTrackResults);
} catch (error) {
console.error("Last.fm search error:", error);
}
}
if (type === "podcasts" || type === "all") {
// Search iTunes Podcast API
try {
const itunesResponse = await axios.get(
"https://itunes.apple.com/search",
{
params: {
term: query,
media: "podcast",
entity: "podcast",
limit: searchLimit,
},
timeout: 5000,
}
);
const podcasts = itunesResponse.data.results.map(
(podcast: any) => ({
type: "podcast",
id: podcast.collectionId,
name: podcast.collectionName,
artist: podcast.artistName,
description: podcast.description,
coverUrl:
podcast.artworkUrl600 || podcast.artworkUrl100,
feedUrl: podcast.feedUrl,
genres: podcast.genres || [],
trackCount: podcast.trackCount,
})
);
results.push(...podcasts);
} catch (error) {
console.error("iTunes podcast search error:", error);
}
}
const payload = { results };
try {
await redisClient.setEx(cacheKey, 900, JSON.stringify(payload));
} catch (err) {
console.warn("[SEARCH DISCOVER] Redis write error:", err);
}
res.json(payload);
} catch (error) {
console.error("Discovery search error:", error);
res.status(500).json({ error: "Discovery search failed" });
}
});
export default router;
+73
View File
@@ -0,0 +1,73 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
const router = Router();
router.use(requireAuth);
const settingsSchema = z.object({
playbackQuality: z.enum(["original", "high", "medium", "low"]).optional(),
wifiOnly: z.boolean().optional(),
offlineEnabled: z.boolean().optional(),
maxCacheSizeMb: z.number().int().min(0).optional(),
});
// GET /settings
router.get("/", async (req, res) => {
try {
const userId = req.user!.id;
let settings = await prisma.userSettings.findUnique({
where: { userId },
});
// Create default settings if they don't exist
if (!settings) {
settings = await prisma.userSettings.create({
data: {
userId,
playbackQuality: "medium",
wifiOnly: false,
offlineEnabled: false,
maxCacheSizeMb: 5120,
},
});
}
res.json(settings);
} catch (error) {
console.error("Get settings error:", error);
res.status(500).json({ error: "Failed to get settings" });
}
});
// POST /settings
router.post("/", async (req, res) => {
try {
const userId = req.user!.id;
const data = settingsSchema.parse(req.body);
const settings = await prisma.userSettings.upsert({
where: { userId },
create: {
userId,
...data,
},
update: data,
});
res.json(settings);
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update settings error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
});
export default router;
+193
View File
@@ -0,0 +1,193 @@
/**
* Soulseek routes - Direct connection via slsk-client
* Simplified API for status and manual search/download
*/
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { soulseekService } from "../services/soulseek";
import { getSystemSettings } from "../utils/systemSettings";
const router = Router();
// Middleware to check if Soulseek credentials are configured
async function requireSoulseekConfigured(req: any, res: any, next: any) {
try {
const available = await soulseekService.isAvailable();
if (!available) {
return res.status(403).json({
error: "Soulseek credentials not configured. Add username/password in System Settings.",
});
}
next();
} catch (error) {
console.error("Error checking Soulseek settings:", error);
res.status(500).json({ error: "Failed to check settings" });
}
}
/**
* GET /soulseek/status
* Check connection status
*/
router.get("/status", requireAuth, async (req, res) => {
try {
const available = await soulseekService.isAvailable();
if (!available) {
return res.json({
enabled: false,
connected: false,
message: "Soulseek credentials not configured",
});
}
const status = await soulseekService.getStatus();
res.json({
enabled: true,
connected: status.connected,
username: status.username,
});
} catch (error: any) {
console.error("Soulseek status error:", error.message);
res.status(500).json({
error: "Failed to get Soulseek status",
details: error.message,
});
}
});
/**
* POST /soulseek/connect
* Manually trigger connection to Soulseek network
*/
router.post("/connect", requireAuth, requireSoulseekConfigured, async (req, res) => {
try {
await soulseekService.connect();
res.json({
success: true,
message: "Connected to Soulseek network",
});
} catch (error: any) {
console.error("Soulseek connect error:", error.message);
res.status(500).json({
error: "Failed to connect to Soulseek",
details: error.message,
});
}
});
/**
* POST /soulseek/search
* Search for a track
*/
router.post("/search", requireAuth, requireSoulseekConfigured, async (req, res) => {
try {
const { artist, title } = req.body;
if (!artist || !title) {
return res.status(400).json({
error: "Artist and title are required",
});
}
console.log(`[Soulseek] Searching: "${artist} - ${title}"`);
const result = await soulseekService.searchTrack(artist, title);
if (result.found && result.bestMatch) {
res.json({
found: true,
match: {
user: result.bestMatch.username,
filename: result.bestMatch.filename,
size: result.bestMatch.size,
quality: result.bestMatch.quality,
score: result.bestMatch.score,
},
});
} else {
res.json({
found: false,
message: "No suitable matches found",
});
}
} catch (error: any) {
console.error("Soulseek search error:", error.message);
res.status(500).json({
error: "Search failed",
details: error.message,
});
}
});
/**
* POST /soulseek/download
* Download a track directly
*/
router.post("/download", requireAuth, requireSoulseekConfigured, async (req, res) => {
try {
const { artist, title, album } = req.body;
if (!artist || !title) {
return res.status(400).json({
error: "Artist and title are required",
});
}
const settings = await getSystemSettings();
const musicPath = settings?.musicPath;
if (!musicPath) {
return res.status(400).json({
error: "Music path not configured",
});
}
console.log(`[Soulseek] Downloading: "${artist} - ${title}"`);
const result = await soulseekService.searchAndDownload(
artist,
title,
album || "Unknown Album",
musicPath
);
if (result.success) {
res.json({
success: true,
filePath: result.filePath,
});
} else {
res.status(404).json({
success: false,
error: result.error || "Download failed",
});
}
} catch (error: any) {
console.error("Soulseek download error:", error.message);
res.status(500).json({
error: "Download failed",
details: error.message,
});
}
});
/**
* POST /soulseek/disconnect
* Disconnect from Soulseek network
*/
router.post("/disconnect", requireAuth, async (req, res) => {
try {
soulseekService.disconnect();
res.json({ success: true, message: "Disconnected" });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;
+334
View File
@@ -0,0 +1,334 @@
import { Router } from "express";
import { requireAuthOrToken } from "../middleware/auth";
import { z } from "zod";
import { spotifyService } from "../services/spotify";
import { spotifyImportService } from "../services/spotifyImport";
import { deezerService } from "../services/deezer";
import { readSessionLog, getSessionLogPath } from "../utils/playlistLogger";
const router = Router();
// All routes require authentication
router.use(requireAuthOrToken);
// Validation schemas
const parseUrlSchema = z.object({
url: z.string().url(),
});
const importSchema = z.object({
spotifyPlaylistId: z.string(),
url: z.string().url().optional(),
playlistName: z.string().min(1).max(200),
albumMbidsToDownload: z.array(z.string()),
});
/**
* POST /api/spotify/parse
* Parse a Spotify URL and return basic info
*/
router.post("/parse", async (req, res) => {
try {
const { url } = parseUrlSchema.parse(req.body);
const parsed = spotifyService.parseUrl(url);
if (!parsed) {
return res.status(400).json({
error: "Invalid Spotify URL. Please provide a valid playlist URL.",
});
}
// For now, only support playlists
if (parsed.type !== "playlist") {
return res.status(400).json({
error: `Only playlist imports are supported. Got: ${parsed.type}`,
});
}
res.json({
type: parsed.type,
id: parsed.id,
url: `https://open.spotify.com/playlist/${parsed.id}`,
});
} catch (error: any) {
console.error("Spotify parse error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
res.status(500).json({ error: error.message || "Failed to parse URL" });
}
});
/**
* POST /api/spotify/preview
* Generate a preview of what will be imported from a Spotify or Deezer playlist
*/
router.post("/preview", async (req, res) => {
try {
const { url } = parseUrlSchema.parse(req.body);
console.log(`[Playlist Import] Generating preview for: ${url}`);
// Detect if it's a Deezer URL
if (url.includes("deezer.com")) {
// Extract playlist ID from Deezer URL
const deezerMatch = url.match(/playlist[\/:](\d+)/);
if (!deezerMatch) {
return res
.status(400)
.json({ error: "Invalid Deezer playlist URL" });
}
const playlistId = deezerMatch[1];
const deezerPlaylist = await deezerService.getPlaylist(playlistId);
if (!deezerPlaylist) {
return res
.status(404)
.json({ error: "Deezer playlist not found" });
}
// Convert Deezer format to Spotify Import format
const preview =
await spotifyImportService.generatePreviewFromDeezer(
deezerPlaylist
);
console.log(
`[Playlist Import] Deezer preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
);
res.json(preview);
} else {
// Handle Spotify URL
const preview = await spotifyImportService.generatePreview(url);
console.log(
`[Spotify Import] Preview generated: ${preview.summary.total} tracks, ${preview.summary.inLibrary} in library`
);
res.json(preview);
}
} catch (error: any) {
console.error("Playlist preview error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
res.status(500).json({
error: error.message || "Failed to generate preview",
});
}
});
/**
* POST /api/spotify/import
* Start importing a Spotify playlist
*/
router.post("/import", async (req, res) => {
try {
const { spotifyPlaylistId, url, playlistName, albumMbidsToDownload } =
importSchema.parse(req.body);
const userId = req.user.id;
// Re-generate preview to ensure fresh data
const effectiveUrl =
url?.trim() ||
`https://open.spotify.com/playlist/${spotifyPlaylistId}`;
let preview;
if (effectiveUrl.includes("deezer.com")) {
const deezerMatch = effectiveUrl.match(/playlist[\/:](\d+)/);
if (!deezerMatch) {
return res
.status(400)
.json({ error: "Invalid Deezer playlist URL" });
}
const playlistId = deezerMatch[1];
const deezerPlaylist = await deezerService.getPlaylist(playlistId);
if (!deezerPlaylist) {
return res
.status(404)
.json({ error: "Deezer playlist not found" });
}
preview = await spotifyImportService.generatePreviewFromDeezer(
deezerPlaylist
);
} else {
preview = await spotifyImportService.generatePreview(effectiveUrl);
}
console.log(
`[Spotify Import] Starting import for user ${userId}: ${playlistName}`
);
console.log(
`[Spotify Import] Downloading ${albumMbidsToDownload.length} albums`
);
const job = await spotifyImportService.startImport(
userId,
spotifyPlaylistId,
playlistName,
albumMbidsToDownload,
preview
);
res.json({
jobId: job.id,
status: job.status,
message: "Import started",
});
} catch (error: any) {
console.error("Spotify import error:", error);
if (error.name === "ZodError") {
return res.status(400).json({ error: "Invalid request body" });
}
res.status(500).json({
error: error.message || "Failed to start import",
});
}
});
/**
* GET /api/spotify/import/:jobId/status
* Get the status of an import job
*/
router.get("/import/:jobId/status", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: "Import job not found" });
}
// Ensure user owns this job
if (job.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to view this job" });
}
res.json(job);
} catch (error: any) {
console.error("Spotify job status error:", error);
res.status(500).json({
error: error.message || "Failed to get job status",
});
}
});
/**
* GET /api/spotify/imports
* Get all import jobs for the current user
*/
router.get("/imports", async (req, res) => {
try {
const userId = req.user.id;
const jobs = await spotifyImportService.getUserJobs(userId);
res.json(jobs);
} catch (error: any) {
console.error("Spotify imports error:", error);
res.status(500).json({
error: error.message || "Failed to get imports",
});
}
});
/**
* POST /api/spotify/import/:jobId/refresh
* Re-match pending tracks and add newly downloaded ones to the playlist
*/
router.post("/import/:jobId/refresh", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: "Import job not found" });
}
// Ensure user owns this job
if (job.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to refresh this job" });
}
const result = await spotifyImportService.refreshJobMatches(jobId);
res.json({
message:
result.added > 0
? `Added ${result.added} newly downloaded track(s)`
: "No new tracks found yet. Albums may still be downloading.",
added: result.added,
total: result.total,
});
} catch (error: any) {
console.error("Spotify refresh error:", error);
res.status(500).json({
error: error.message || "Failed to refresh tracks",
});
}
});
/**
* POST /api/spotify/import/:jobId/cancel
* Cancel an import job and create playlist with whatever succeeded
*/
router.post("/import/:jobId/cancel", async (req, res) => {
try {
const { jobId } = req.params;
const userId = req.user.id;
const job = await spotifyImportService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: "Import job not found" });
}
// Ensure user owns this job
if (job.userId !== userId) {
return res
.status(403)
.json({ error: "Not authorized to cancel this job" });
}
const result = await spotifyImportService.cancelJob(jobId);
res.json({
message: result.playlistCreated
? `Import cancelled. Playlist created with ${result.tracksMatched} track(s).`
: "Import cancelled. No tracks were downloaded.",
playlistId: result.playlistId,
tracksMatched: result.tracksMatched,
});
} catch (error: any) {
console.error("Spotify cancel error:", error);
res.status(500).json({
error: error.message || "Failed to cancel import",
});
}
});
/**
* GET /api/spotify/import/session-log
* Get the current session log for debugging import issues
*/
router.get("/import/session-log", async (req, res) => {
try {
const log = readSessionLog();
const logPath = getSessionLogPath();
res.json({
path: logPath,
content: log,
});
} catch (error: any) {
console.error("Session log error:", error);
res.status(500).json({
error: error.message || "Failed to read session log",
});
}
});
export default router;
+712
View File
@@ -0,0 +1,712 @@
import { Router } from "express";
import { requireAuth, requireAdmin } from "../middleware/auth";
import { prisma } from "../utils/db";
import { z } from "zod";
import { writeEnvFile } from "../utils/envWriter";
import { invalidateSystemSettingsCache } from "../utils/systemSettings";
import { queueCleaner } from "../jobs/queueCleaner";
import { encrypt, decrypt } from "../utils/encryption";
const router = Router();
/**
* Safely decrypt a field, returning null if decryption fails
*/
function safeDecrypt(value: string | null): string | null {
if (!value) return null;
try {
return decrypt(value);
} catch (error) {
console.warn("[Settings Route] Failed to decrypt field, returning null");
return null;
}
}
// Only admins can access system settings
router.use(requireAuth);
router.use(requireAdmin);
const systemSettingsSchema = z.object({
// Download Services
lidarrEnabled: z.boolean().optional(),
lidarrUrl: z.string().optional(),
lidarrApiKey: z.string().nullable().optional(),
// AI Services
openaiEnabled: z.boolean().optional(),
openaiApiKey: z.string().nullable().optional(),
openaiModel: z.string().optional(),
openaiBaseUrl: z.string().nullable().optional(),
fanartEnabled: z.boolean().optional(),
fanartApiKey: z.string().nullable().optional(),
// Media Services
audiobookshelfEnabled: z.boolean().optional(),
audiobookshelfUrl: z.string().optional(),
audiobookshelfApiKey: z.string().nullable().optional(),
// Soulseek (direct connection via slsk-client)
soulseekUsername: z.string().nullable().optional(),
soulseekPassword: z.string().nullable().optional(),
// Spotify (for playlist import)
spotifyClientId: z.string().nullable().optional(),
spotifyClientSecret: z.string().nullable().optional(),
// Storage Paths
musicPath: z.string().optional(),
downloadPath: z.string().optional(),
// Feature Flags
autoSync: z.boolean().optional(),
autoEnrichMetadata: z.boolean().optional(),
// Advanced Settings
maxConcurrentDownloads: z.number().optional(),
downloadRetryAttempts: z.number().optional(),
transcodeCacheMaxGb: z.number().optional(),
// Download Preferences
downloadSource: z.enum(["soulseek", "lidarr"]).optional(),
soulseekFallback: z.enum(["none", "lidarr"]).optional(),
});
// GET /system-settings
router.get("/", async (req, res) => {
try {
let settings = await prisma.systemSettings.findUnique({
where: { id: "default" },
});
// Create default settings if they don't exist
if (!settings) {
settings = await prisma.systemSettings.create({
data: {
id: "default",
lidarrEnabled: true,
lidarrUrl: "http://localhost:8686",
openaiEnabled: false,
openaiModel: "gpt-4",
fanartEnabled: false,
audiobookshelfEnabled: false,
audiobookshelfUrl: "http://localhost:13378",
musicPath: "/music",
downloadPath: "/downloads",
autoSync: true,
autoEnrichMetadata: true,
maxConcurrentDownloads: 3,
downloadRetryAttempts: 3,
transcodeCacheMaxGb: 10,
},
});
}
// Decrypt sensitive fields before sending to client
// Use safeDecrypt to handle corrupted encrypted values gracefully
const decryptedSettings = {
...settings,
lidarrApiKey: safeDecrypt(settings.lidarrApiKey),
openaiApiKey: safeDecrypt(settings.openaiApiKey),
fanartApiKey: safeDecrypt(settings.fanartApiKey),
audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey),
soulseekPassword: safeDecrypt(settings.soulseekPassword),
spotifyClientSecret: safeDecrypt(settings.spotifyClientSecret),
};
res.json(decryptedSettings);
} catch (error) {
console.error("Get system settings error:", error);
res.status(500).json({ error: "Failed to get system settings" });
}
});
// POST /system-settings
router.post("/", async (req, res) => {
try {
const data = systemSettingsSchema.parse(req.body);
console.log("[SYSTEM SETTINGS] Saving settings...");
console.log(
"[SYSTEM SETTINGS] transcodeCacheMaxGb:",
data.transcodeCacheMaxGb
);
// Encrypt sensitive fields
const encryptedData: any = { ...data };
if (data.lidarrApiKey)
encryptedData.lidarrApiKey = encrypt(data.lidarrApiKey);
if (data.openaiApiKey)
encryptedData.openaiApiKey = encrypt(data.openaiApiKey);
if (data.fanartApiKey)
encryptedData.fanartApiKey = encrypt(data.fanartApiKey);
if (data.audiobookshelfApiKey)
encryptedData.audiobookshelfApiKey = encrypt(
data.audiobookshelfApiKey
);
if (data.soulseekPassword)
encryptedData.soulseekPassword = encrypt(data.soulseekPassword);
if (data.spotifyClientSecret)
encryptedData.spotifyClientSecret = encrypt(data.spotifyClientSecret);
const settings = await prisma.systemSettings.upsert({
where: { id: "default" },
create: {
id: "default",
...encryptedData,
},
update: encryptedData,
});
invalidateSystemSettingsCache();
// If Audiobookshelf was disabled, clear all audiobook-related data
if (data.audiobookshelfEnabled === false) {
console.log(
"[CLEANUP] Audiobookshelf disabled - clearing all audiobook data from database"
);
try {
const deletedProgress =
await prisma.audiobookProgress.deleteMany({});
console.log(
` Deleted ${deletedProgress.count} audiobook progress entries`
);
} catch (clearError) {
console.error("Failed to clear audiobook data:", clearError);
// Don't fail the request
}
}
// Write to .env file for Docker containers
try {
await writeEnvFile({
LIDARR_ENABLED: data.lidarrEnabled ? "true" : "false",
LIDARR_URL: data.lidarrUrl || null,
LIDARR_API_KEY: data.lidarrApiKey || null,
FANART_API_KEY: data.fanartApiKey || null,
OPENAI_API_KEY: data.openaiApiKey || null,
AUDIOBOOKSHELF_URL: data.audiobookshelfUrl || null,
AUDIOBOOKSHELF_API_KEY: data.audiobookshelfApiKey || null,
SOULSEEK_USERNAME: data.soulseekUsername || null,
SOULSEEK_PASSWORD: data.soulseekPassword || null,
});
console.log(".env file synchronized with database settings");
} catch (envError) {
console.error("Failed to write .env file:", envError);
// Don't fail the request if .env write fails
}
// Auto-configure Lidarr webhook if Lidarr is enabled
if (data.lidarrEnabled && data.lidarrUrl && data.lidarrApiKey) {
try {
console.log("[LIDARR] Auto-configuring webhook...");
const axios = (await import("axios")).default;
const lidarrUrl = data.lidarrUrl;
const apiKey = data.lidarrApiKey;
// Determine webhook URL
// Use LIDIFY_CALLBACK_URL env var if set, otherwise default to host.docker.internal:3030
// Port 3030 is the external Nginx port that Lidarr can reach
const callbackHost = process.env.LIDIFY_CALLBACK_URL || "http://host.docker.internal:3030";
const webhookUrl = `${callbackHost}/api/webhooks/lidarr`;
console.log(` Webhook URL: ${webhookUrl}`);
// Check if webhook already exists - find by name "Lidify" OR by URL containing "lidify" or "webhooks/lidarr"
const notificationsResponse = await axios.get(
`${lidarrUrl}/api/v1/notification`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
// Find existing Lidify webhook by name (primary) or URL pattern (fallback)
const existingWebhook = notificationsResponse.data.find(
(n: any) =>
n.implementation === "Webhook" &&
(
// Match by name
n.name === "Lidify" ||
// Or match by URL pattern (catches old webhooks with different URLs)
n.fields?.find(
(f: any) =>
f.name === "url" &&
(f.value?.includes("webhooks/lidarr") || f.value?.includes("lidify"))
)
)
);
if (existingWebhook) {
const currentUrl = existingWebhook.fields?.find((f: any) => f.name === "url")?.value;
console.log(` Found existing webhook: "${existingWebhook.name}" with URL: ${currentUrl}`);
if (currentUrl !== webhookUrl) {
console.log(` URL needs updating from: ${currentUrl}`);
console.log(` URL will be updated to: ${webhookUrl}`);
}
}
const webhookConfig = {
onGrab: true,
onReleaseImport: true,
onAlbumDownload: true,
onDownloadFailure: true,
onImportFailure: true,
onAlbumDelete: true,
onRename: true,
onHealthIssue: false,
onApplicationUpdate: false,
supportsOnGrab: true,
supportsOnReleaseImport: true,
supportsOnAlbumDownload: true,
supportsOnDownloadFailure: true,
supportsOnImportFailure: true,
supportsOnAlbumDelete: true,
supportsOnRename: true,
supportsOnHealthIssue: true,
supportsOnApplicationUpdate: true,
includeHealthWarnings: false,
name: "Lidify",
implementation: "Webhook",
implementationName: "Webhook",
configContract: "WebhookSettings",
infoLink:
"https://wiki.servarr.com/lidarr/supported#webhook",
tags: [],
fields: [
{ name: "url", value: webhookUrl },
{ name: "method", value: 1 }, // 1 = POST
{ name: "username", value: "" },
{ name: "password", value: "" },
],
};
if (existingWebhook) {
// Update existing webhook
await axios.put(
`${lidarrUrl}/api/v1/notification/${existingWebhook.id}?forceSave=true`,
{ ...existingWebhook, ...webhookConfig },
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(" Webhook updated");
} else {
// Create new webhook (use forceSave to skip test)
await axios.post(
`${lidarrUrl}/api/v1/notification?forceSave=true`,
webhookConfig,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(" Webhook created");
}
console.log("Lidarr webhook configured automatically\n");
} catch (webhookError: any) {
console.error(
"Failed to auto-configure webhook:",
webhookError.message
);
if (webhookError.response?.data) {
console.error(
" Lidarr error details:",
JSON.stringify(webhookError.response.data, null, 2)
);
}
console.log(
" User can configure webhook manually in Lidarr UI\n"
);
// Don't fail the request if webhook config fails
}
}
res.json({
success: true,
message:
"Settings saved successfully. Restart Docker containers to apply changes.",
requiresRestart: true,
});
} catch (error) {
if (error instanceof z.ZodError) {
return res
.status(400)
.json({ error: "Invalid settings", details: error.errors });
}
console.error("Update system settings error:", error);
res.status(500).json({ error: "Failed to update system settings" });
}
});
// POST /system-settings/test-lidarr
router.post("/test-lidarr", async (req, res) => {
try {
const { url, apiKey } = req.body;
console.log("[Lidarr Test] Testing connection to:", url);
if (!url || !apiKey) {
return res
.status(400)
.json({ error: "URL and API key are required" });
}
// Normalize URL - remove trailing slash
const normalizedUrl = url.replace(/\/+$/, "");
const axios = require("axios");
const response = await axios.get(
`${normalizedUrl}/api/v1/system/status`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(
"[Lidarr Test] Connection successful, version:",
response.data.version
);
res.json({
success: true,
message: "Lidarr connection successful",
version: response.data.version,
});
} catch (error: any) {
console.error("[Lidarr Test] Error:", error.message);
console.error(
"[Lidarr Test] Details:",
error.response?.data || error.code
);
let details = error.message;
if (error.code === "ECONNREFUSED") {
details =
"Connection refused - check if Lidarr is running and accessible";
} else if (error.code === "ENOTFOUND") {
details = "Host not found - check the URL";
} else if (error.response?.status === 401) {
details = "Invalid API key";
} else if (error.response?.data?.message) {
details = error.response.data.message;
}
res.status(500).json({
error: "Failed to connect to Lidarr",
details,
});
}
});
// POST /system-settings/test-openai
router.post("/test-openai", async (req, res) => {
try {
const { apiKey, model } = req.body;
if (!apiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
const response = await axios.post(
"https://api.openai.com/v1/chat/completions",
{
model: model || "gpt-3.5-turbo",
messages: [{ role: "user", content: "Test" }],
max_tokens: 5,
},
{
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 10000,
}
);
res.json({
success: true,
message: "OpenAI connection successful",
model: response.data.model,
});
} catch (error: any) {
console.error("OpenAI test error:", error.message);
res.status(500).json({
error: "Failed to connect to OpenAI",
details: error.response?.data?.error?.message || error.message,
});
}
});
// Test Fanart.tv connection
router.post("/test-fanart", async (req, res) => {
try {
const { fanartApiKey } = req.body;
if (!fanartApiKey) {
return res.status(400).json({ error: "API key is required" });
}
const axios = require("axios");
// Test with a known artist (The Beatles MBID)
const testMbid = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d";
const response = await axios.get(
`https://webservice.fanart.tv/v3/music/${testMbid}`,
{
params: { api_key: fanartApiKey },
timeout: 5000,
}
);
// If we get here, the API key is valid
res.json({
success: true,
message: "Fanart.tv connection successful",
});
} catch (error: any) {
console.error("Fanart.tv test error:", error.message);
if (error.response?.status === 401) {
res.status(401).json({
error: "Invalid Fanart.tv API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Fanart.tv",
details: error.response?.data || error.message,
});
}
}
});
// Test Audiobookshelf connection
router.post("/test-audiobookshelf", async (req, res) => {
try {
const { url, apiKey } = req.body;
if (!url || !apiKey) {
return res
.status(400)
.json({ error: "URL and API key are required" });
}
const axios = require("axios");
const response = await axios.get(`${url}/api/libraries`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
timeout: 5000,
});
res.json({
success: true,
message: "Audiobookshelf connection successful",
libraries: response.data.libraries?.length || 0,
});
} catch (error: any) {
console.error("Audiobookshelf test error:", error.message);
if (error.response?.status === 401 || error.response?.status === 403) {
res.status(401).json({
error: "Invalid Audiobookshelf API key",
});
} else {
res.status(500).json({
error: "Failed to connect to Audiobookshelf",
details: error.response?.data || error.message,
});
}
}
});
// Test Soulseek connection (direct via slsk-client)
router.post("/test-soulseek", async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
error: "Soulseek username and password are required",
});
}
console.log(`[SOULSEEK-TEST] Testing connection as "${username}"...`);
// Import soulseek service
const { soulseekService } = await import("../services/soulseek");
// Temporarily set credentials for test
// The service will use the provided credentials
try {
// Try to connect with the provided credentials
const slsk = require("slsk-client");
await new Promise<void>((resolve, reject) => {
slsk.connect(
{ user: username, pass: password },
(err: Error | null, client: any) => {
if (err) {
console.log(`[SOULSEEK-TEST] Connection failed: ${err.message}`);
return reject(err);
}
console.log(`[SOULSEEK-TEST] Connected successfully`);
// We don't need to keep the connection open for the test
resolve();
}
);
});
res.json({
success: true,
message: `Connected to Soulseek as "${username}"`,
soulseekUsername: username,
isConnected: true,
});
} catch (connectError: any) {
console.error(`[SOULSEEK-TEST] Error: ${connectError.message}`);
res.status(401).json({
error: "Invalid Soulseek credentials or connection failed",
details: connectError.message,
});
}
} catch (error: any) {
console.error("[SOULSEEK-TEST] Error:", error.message);
res.status(500).json({
error: "Failed to test Soulseek connection",
details: error.message,
});
}
});
// Test Spotify credentials
router.post("/test-spotify", async (req, res) => {
try {
const { clientId, clientSecret } = req.body;
if (!clientId || !clientSecret) {
return res.status(400).json({
error: "Client ID and Client Secret are required"
});
}
// Import spotifyService to test credentials
const { spotifyService } = await import("../services/spotify");
const result = await spotifyService.testCredentials(clientId, clientSecret);
if (result.success) {
res.json({
success: true,
message: "Spotify credentials are valid",
});
} else {
res.status(401).json({
error: result.error || "Invalid Spotify credentials",
});
}
} catch (error: any) {
console.error("Spotify test error:", error.message);
res.status(500).json({
error: "Failed to test Spotify credentials",
details: error.message,
});
}
});
// Get queue cleaner status
router.get("/queue-cleaner-status", (req, res) => {
res.json(queueCleaner.getStatus());
});
// Start queue cleaner manually
router.post("/queue-cleaner/start", async (req, res) => {
try {
await queueCleaner.start();
res.json({
success: true,
message: "Queue cleaner started",
status: queueCleaner.getStatus(),
});
} catch (error: any) {
res.status(500).json({
error: "Failed to start queue cleaner",
details: error.message,
});
}
});
// Stop queue cleaner manually
router.post("/queue-cleaner/stop", (req, res) => {
queueCleaner.stop();
res.json({
success: true,
message: "Queue cleaner stopped",
status: queueCleaner.getStatus(),
});
});
// Clear all Redis caches
router.post("/clear-caches", async (req, res) => {
try {
const { redisClient } = require("../utils/redis");
const { notificationService } = await import("../services/notificationService");
// Get all keys but exclude session keys
const allKeys = await redisClient.keys("*");
const keysToDelete = allKeys.filter(
(key: string) => !key.startsWith("sess:")
);
if (keysToDelete.length > 0) {
console.log(
`[CACHE] Clearing ${
keysToDelete.length
} cache entries (excluding ${
allKeys.length - keysToDelete.length
} session keys)...`
);
for (const key of keysToDelete) {
await redisClient.del(key);
}
console.log(
`[CACHE] Successfully cleared ${keysToDelete.length} cache entries`
);
// Send notification to user
await notificationService.notifySystem(
req.user!.id,
"Caches Cleared",
`Successfully cleared ${keysToDelete.length} cache entries`
);
res.json({
success: true,
message: `Cleared ${keysToDelete.length} cache entries`,
clearedKeys: keysToDelete.length,
});
} else {
await notificationService.notifySystem(
req.user!.id,
"Caches Cleared",
"No cache entries to clear"
);
res.json({
success: true,
message: "No cache entries to clear",
clearedKeys: 0,
});
}
} catch (error: any) {
console.error("Clear caches error:", error);
res.status(500).json({
error: "Failed to clear caches",
details: error.message,
});
}
});
export default router;
+231
View File
@@ -0,0 +1,231 @@
/**
* Lidarr Webhook Handler (Refactored)
*
* Handles Lidarr webhooks for download tracking and Discovery Weekly integration.
* Uses the stateless simpleDownloadManager for all operations.
*/
import { Router } from "express";
import { prisma } from "../utils/db";
import { scanQueue } from "../workers/queues";
import { discoverWeeklyService } from "../services/discoverWeekly";
import { simpleDownloadManager } from "../services/simpleDownloadManager";
import { queueCleaner } from "../jobs/queueCleaner";
import { getSystemSettings } from "../utils/systemSettings";
const router = Router();
// POST /webhooks/lidarr - Handle Lidarr webhooks
router.post("/lidarr", async (req, res) => {
try {
// Check if Lidarr is enabled before processing any webhooks
const settings = await getSystemSettings();
if (
!settings?.lidarrEnabled ||
!settings?.lidarrUrl ||
!settings?.lidarrApiKey
) {
console.log(
`[WEBHOOK] Lidarr webhook received but Lidarr is disabled. Ignoring.`
);
return res.status(202).json({
success: true,
ignored: true,
reason: "lidarr-disabled",
});
}
const eventType = req.body.eventType;
console.log(`[WEBHOOK] Lidarr event: ${eventType}`);
// Log payload in debug mode only (avoid verbose logs in production)
if (process.env.DEBUG_WEBHOOKS === "true") {
console.log(` Payload:`, JSON.stringify(req.body, null, 2));
}
switch (eventType) {
case "Grab":
await handleGrab(req.body);
break;
case "Download":
case "AlbumDownload":
case "TrackRetag":
case "Rename":
await handleDownload(req.body);
break;
case "ImportFailure":
case "DownloadFailed":
case "DownloadFailure":
await handleImportFailure(req.body);
break;
case "Health":
case "HealthIssue":
case "HealthRestored":
// Ignore health events
break;
case "Test":
console.log(" Lidarr test webhook received");
break;
default:
console.log(` Unhandled event: ${eventType}`);
}
res.json({ success: true });
} catch (error: any) {
console.error("Webhook error:", error.message);
res.status(500).json({ error: "Webhook processing failed" });
}
});
/**
* Handle Grab event (download started by Lidarr)
*/
async function handleGrab(payload: any) {
const downloadId = payload.downloadId;
const albumMbid =
payload.albums?.[0]?.foreignAlbumId || payload.albums?.[0]?.mbId;
const albumTitle = payload.albums?.[0]?.title;
const artistName = payload.artist?.name;
const lidarrAlbumId = payload.albums?.[0]?.id;
console.log(` Album: ${artistName} - ${albumTitle}`);
console.log(` Download ID: ${downloadId}`);
console.log(` MBID: ${albumMbid}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
return;
}
// Use the download manager's multi-strategy matching
const result = await simpleDownloadManager.onDownloadGrabbed(
downloadId,
albumMbid || "",
albumTitle || "",
artistName || "",
lidarrAlbumId || 0
);
if (result.matched) {
// Start queue cleaner to monitor this download
queueCleaner.start();
}
}
/**
* Handle Download event (download complete + imported)
*/
async function handleDownload(payload: any) {
const downloadId = payload.downloadId;
const albumTitle = payload.album?.title || payload.albums?.[0]?.title;
const artistName = payload.artist?.name;
const albumMbid =
payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
const lidarrAlbumId = payload.album?.id || payload.albums?.[0]?.id;
console.log(` Album: ${artistName} - ${albumTitle}`);
console.log(` Download ID: ${downloadId}`);
console.log(` Album MBID: ${albumMbid}`);
console.log(` Lidarr Album ID: ${lidarrAlbumId}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
return;
}
// Handle completion through download manager
const result = await simpleDownloadManager.onDownloadComplete(
downloadId,
albumMbid,
artistName,
albumTitle,
lidarrAlbumId
);
if (result.jobId) {
// Check if this is part of a download batch (artist download)
if (result.downloadBatchId) {
// Check if all jobs in the batch are complete
const batchComplete = await checkDownloadBatchComplete(
result.downloadBatchId
);
if (batchComplete) {
console.log(
` All albums in batch complete, triggering library scan...`
);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import-batch",
});
} else {
console.log(` Batch not complete, skipping scan`);
}
} else if (!result.batchId) {
// Single album download (not part of discovery batch)
console.log(` Triggering library scan...`);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import",
});
}
// If part of discovery batch, the download manager already called checkBatchCompletion
} else {
// No job found - this might be an external download not initiated by us
// Still trigger a scan to pick up the new music
console.log(` No matching job, triggering scan anyway...`);
await scanQueue.add("scan", {
type: "full",
source: "lidarr-import-external",
});
}
}
/**
* Check if all jobs in a download batch are complete
*/
async function checkDownloadBatchComplete(batchId: string): Promise<boolean> {
const pendingJobs = await prisma.downloadJob.count({
where: {
metadata: {
path: ["batchId"],
equals: batchId,
},
status: { in: ["pending", "processing"] },
},
});
console.log(
` Batch ${batchId}: ${pendingJobs} pending/processing jobs remaining`
);
return pendingJobs === 0;
}
/**
* Handle import failure with automatic retry
*/
async function handleImportFailure(payload: any) {
const downloadId = payload.downloadId;
const albumMbid =
payload.album?.foreignAlbumId || payload.albums?.[0]?.foreignAlbumId;
const albumTitle = payload.album?.title || payload.release?.title;
const reason = payload.message || "Import failed";
console.log(` Album: ${albumTitle}`);
console.log(` Download ID: ${downloadId}`);
console.log(` Reason: ${reason}`);
if (!downloadId) {
console.log(` Missing downloadId, skipping`);
return;
}
// Handle failure through download manager (handles retry logic)
await simpleDownloadManager.onImportFailed(downloadId, reason, albumMbid);
}
export default router;
+395
View File
@@ -0,0 +1,395 @@
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { prisma } from "../utils/db";
import ffmpeg from "fluent-ffmpeg";
import ffmpegPath from "@ffmpeg-installer/ffmpeg";
import PQueue from "p-queue";
import { AppError, ErrorCode, ErrorCategory } from "../utils/errors";
import { parseFile } from "music-metadata";
// Set FFmpeg path to bundled binary
ffmpeg.setFfmpegPath(ffmpegPath.path);
// Quality settings
export const QUALITY_SETTINGS = {
original: { bitrate: null, format: null }, // No transcoding
high: { bitrate: 320, format: "mp3" },
medium: { bitrate: 192, format: "mp3" },
low: { bitrate: 128, format: "mp3" },
} as const;
export type Quality = keyof typeof QUALITY_SETTINGS;
interface StreamFileInfo {
filePath: string;
mimeType: string;
}
export class AudioStreamingService {
private transcodeQueue = new PQueue({ concurrency: 3 });
private musicPath: string;
private transcodeCachePath: string;
private transcodeCacheMaxGb: number;
private evictionInterval: NodeJS.Timeout | null = null;
constructor(
musicPath: string,
transcodeCachePath: string,
transcodeCacheMaxGb: number
) {
this.musicPath = musicPath;
this.transcodeCachePath = transcodeCachePath;
this.transcodeCacheMaxGb = transcodeCacheMaxGb;
// Ensure cache directory exists
if (!fs.existsSync(this.transcodeCachePath)) {
fs.mkdirSync(this.transcodeCachePath, { recursive: true });
}
// Start cache eviction timer (every 6 hours)
this.evictionInterval = setInterval(() => {
this.evictCache(this.transcodeCacheMaxGb).catch((err) => {
console.error("Cache eviction failed:", err);
});
}, 6 * 60 * 60 * 1000);
}
/**
* Get file path for streaming (either original or transcoded)
*/
async getStreamFilePath(
trackId: string,
quality: Quality,
sourceModified: Date,
sourceAbsolutePath: string
): Promise<StreamFileInfo> {
console.log(`[AudioStreaming] Request: trackId=${trackId}, quality=${quality}, source=${path.basename(sourceAbsolutePath)}`);
// If original quality requested, return source file
if (quality === "original") {
const mimeType = this.getMimeType(sourceAbsolutePath);
console.log(`[AudioStreaming] Serving original: mimeType=${mimeType}`);
return {
filePath: sourceAbsolutePath,
mimeType,
};
}
// Check if we have a valid cached transcode
const cachedPath = await this.getCachedTranscode(
trackId,
quality,
sourceModified
);
if (cachedPath) {
console.log(
`[STREAM] Using cached transcode: ${quality} (${cachedPath})`
);
return {
filePath: cachedPath,
mimeType: "audio/mpeg",
};
}
// Check source file bitrate to avoid pointless upsampling
const targetBitrate = QUALITY_SETTINGS[quality].bitrate;
if (targetBitrate) {
try {
const metadata = await parseFile(sourceAbsolutePath);
const sourceBitrate = metadata.format.bitrate
? Math.round(metadata.format.bitrate / 1000)
: null;
if (sourceBitrate && sourceBitrate <= targetBitrate) {
console.log(
`[STREAM] Source bitrate (${sourceBitrate}kbps) <= target (${targetBitrate}kbps), serving original`
);
return {
filePath: sourceAbsolutePath,
mimeType: this.getMimeType(sourceAbsolutePath),
};
}
} catch (err) {
console.warn(
`[STREAM] Failed to read source metadata, will transcode anyway:`,
err
);
}
}
// Need to transcode - check cache size first
const currentSize = await this.getCacheSize();
if (currentSize > this.transcodeCacheMaxGb * 0.9) {
console.log(
`[STREAM] Cache near full (${currentSize.toFixed(
2
)}GB), evicting to 80%...`
);
await this.evictCache(this.transcodeCacheMaxGb * 0.8);
}
// Transcode to cache
console.log(
`[STREAM] Transcoding to ${quality} quality: ${sourceAbsolutePath}`
);
const transcodedPath = await this.transcodeToCache(
trackId,
quality,
sourceAbsolutePath,
sourceModified
);
return {
filePath: transcodedPath,
mimeType: "audio/mpeg",
};
}
/**
* Get cached transcode if it exists and is valid
*/
private async getCachedTranscode(
trackId: string,
quality: Quality,
sourceModified: Date
): Promise<string | null> {
const cached = await prisma.transcodedFile.findFirst({
where: {
trackId,
quality,
},
});
if (!cached) return null;
// Invalidate if source file was modified after transcode was created
if (cached.sourceModified < sourceModified) {
console.log(
`[STREAM] Cache stale for track ${trackId}, removing...`
);
await prisma.transcodedFile.delete({ where: { id: cached.id } });
// Delete file from disk
const cachePath = path.join(
this.transcodeCachePath,
cached.cachePath
);
await fs.promises.unlink(cachePath).catch(() => {});
return null;
}
// Update last accessed time
await prisma.transcodedFile.update({
where: { id: cached.id },
data: { lastAccessed: new Date() },
});
const fullPath = path.join(this.transcodeCachePath, cached.cachePath);
// Verify file exists
if (!fs.existsSync(fullPath)) {
console.log(`[STREAM] Cache file missing: ${fullPath}`);
await prisma.transcodedFile.delete({ where: { id: cached.id } });
return null;
}
return fullPath;
}
/**
* Transcode audio file to cache
*/
private async transcodeToCache(
trackId: string,
quality: Quality,
sourcePath: string,
sourceModified: Date
): Promise<string> {
const settings = QUALITY_SETTINGS[quality];
if (!settings.bitrate || !settings.format) {
throw new AppError(
ErrorCode.INVALID_CONFIG,
ErrorCategory.FATAL,
`Invalid quality setting: ${quality}`
);
}
// Generate cache file path
const hash = crypto
.createHash("md5")
.update(`${trackId}-${quality}`)
.digest("hex");
const cacheFileName = `${hash}.${settings.format}`;
const cachePath = path.join(this.transcodeCachePath, cacheFileName);
return new Promise((resolve, reject) => {
try {
ffmpeg(sourcePath)
.audioBitrate(settings.bitrate)
.audioCodec("libmp3lame")
.format(settings.format)
.on("error", (err) => {
// Check if error is due to missing FFmpeg
const errorMsg = err.message.toLowerCase();
if (
errorMsg.includes("ffmpeg") &&
errorMsg.includes("not found")
) {
reject(
new AppError(
ErrorCode.FFMPEG_NOT_FOUND,
ErrorCategory.FATAL,
"FFmpeg not installed. Please install FFmpeg to enable transcoding.",
{ trackId, quality }
)
);
} else {
reject(
new AppError(
ErrorCode.TRANSCODE_FAILED,
ErrorCategory.RECOVERABLE,
`Transcoding failed: ${err.message}`,
{ trackId, quality, source: sourcePath }
)
);
}
})
.on("end", async () => {
try {
// Get file size
const stats = await fs.promises.stat(cachePath);
// Save to database
await prisma.transcodedFile.create({
data: {
trackId,
quality,
cachePath: cacheFileName,
cacheSize: stats.size,
sourceModified,
lastAccessed: new Date(),
},
});
console.log(
`[STREAM] Transcode complete: ${cacheFileName} (${(
stats.size /
1024 /
1024
).toFixed(2)}MB)`
);
resolve(cachePath);
} catch (err: any) {
reject(
new AppError(
ErrorCode.DB_QUERY_ERROR,
ErrorCategory.RECOVERABLE,
`Failed to save transcode record: ${err.message}`,
{ trackId, quality }
)
);
}
})
.save(cachePath);
} catch (err: any) {
reject(
new AppError(
ErrorCode.FFMPEG_NOT_FOUND,
ErrorCategory.FATAL,
"FFmpeg not available. Please install FFmpeg to enable transcoding.",
{ trackId, quality }
)
);
}
});
}
/**
* Get total cache size in GB
*/
async getCacheSize(): Promise<number> {
const cached = await prisma.transcodedFile.findMany({
select: { cacheSize: true },
});
const totalBytes = cached.reduce((sum, f) => sum + f.cacheSize, 0);
return totalBytes / (1024 * 1024 * 1024);
}
/**
* Evict cache using LRU until size is below target
*/
async evictCache(targetGb: number): Promise<void> {
console.log(`[CACHE] Starting eviction, target: ${targetGb}GB`);
let currentSize = await this.getCacheSize();
console.log(`[CACHE] Current size: ${currentSize.toFixed(2)}GB`);
if (currentSize <= targetGb) {
console.log("[CACHE] Below target, no eviction needed");
return;
}
// Get all cached files sorted by last accessed (oldest first)
const cached = await prisma.transcodedFile.findMany({
orderBy: { lastAccessed: "asc" },
});
let evicted = 0;
for (const file of cached) {
if (currentSize <= targetGb) break;
// Delete file from disk
const fullPath = path.join(this.transcodeCachePath, file.cachePath);
try {
await fs.promises.unlink(fullPath);
} catch (err) {
console.warn(`[CACHE] Failed to delete ${fullPath}:`, err);
}
// Delete from database
await prisma.transcodedFile.delete({ where: { id: file.id } });
currentSize -= file.cacheSize / (1024 * 1024 * 1024);
evicted++;
}
console.log(
`[CACHE] Evicted ${evicted} files, new size: ${currentSize.toFixed(
2
)}GB`
);
}
/**
* Get MIME type from file extension
*/
getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: Record<string, string> = {
".mp3": "audio/mpeg",
".flac": "audio/flac",
".m4a": "audio/mp4",
".aac": "audio/aac",
".ogg": "audio/ogg",
".opus": "audio/opus",
".wav": "audio/wav",
".wma": "audio/x-ms-wma",
".ape": "audio/x-ape",
".wv": "audio/x-wavpack",
};
return mimeTypes[ext] || "audio/mpeg";
}
/**
* Cleanup resources
*/
destroy(): void {
if (this.evictionInterval) {
clearInterval(this.evictionInterval);
this.evictionInterval = null;
}
}
}
+416
View File
@@ -0,0 +1,416 @@
import { audiobookshelfService } from "./audiobookshelf";
import { prisma } from "../utils/db";
import fs from "fs/promises";
import path from "path";
import { config } from "../config";
/**
* Service to sync audiobooks from Audiobookshelf and cache them locally
* This allows us to serve audiobook metadata from our database instead of hitting
* the Audiobookshelf API every time, dramatically improving performance
*/
interface SyncResult {
synced: number;
failed: number;
skipped: number;
errors: string[];
}
export class AudiobookCacheService {
private coverCacheDir: string;
constructor() {
// Store covers in: <MUSIC_PATH>/cover-cache/audiobooks/
this.coverCacheDir = path.join(
config.music.musicPath,
"cover-cache",
"audiobooks"
);
}
/**
* Sync all audiobooks from Audiobookshelf to our database
*/
async syncAll(): Promise<SyncResult> {
const result: SyncResult = {
synced: 0,
failed: 0,
skipped: 0,
errors: [],
};
try {
console.log(" Starting audiobook sync from Audiobookshelf...");
// Ensure cover cache directory exists
await fs.mkdir(this.coverCacheDir, { recursive: true });
// Fetch all audiobooks from Audiobookshelf
const audiobooks = await audiobookshelfService.getAllAudiobooks();
console.log(
`[AUDIOBOOK] Found ${audiobooks.length} audiobooks in Audiobookshelf`
);
for (const book of audiobooks) {
try {
await this.syncAudiobook(book);
result.synced++;
// Extract title and author from nested structure for logging
const metadata = book.media?.metadata || book;
const title =
metadata.title || book.title || "Unknown Title";
const author =
metadata.authorName ||
metadata.author ||
book.author ||
"Unknown Author";
console.log(` Synced: ${title} by ${author}`);
} catch (error: any) {
result.failed++;
const metadata = book.media?.metadata || book;
const title =
metadata.title || book.title || "Unknown Title";
const errorMsg = `Failed to sync ${title}: ${error.message}`;
result.errors.push(errorMsg);
console.error(`${errorMsg}`);
}
}
console.log("\nSync Summary:");
console.log(` Synced: ${result.synced}`);
console.log(` Failed: ${result.failed}`);
console.log(` Skipped: ${result.skipped}`);
if (result.errors.length > 0) {
console.log("\n[ERRORS]:");
result.errors.forEach((err) => console.log(` - ${err}`));
}
return result;
} catch (error: any) {
console.error(" Audiobook sync failed:", error);
throw error;
}
}
/**
* Sync a single audiobook
*/
private async syncAudiobook(book: any): Promise<void> {
// Extract metadata from Audiobookshelf API response structure
// The API returns: { id, media: { metadata: { title, author, ... } } }
const metadata = book.media?.metadata || book;
const title = metadata.title || book.title;
// Skip if no title (invalid audiobook data)
if (!title) {
console.warn(` Skipping audiobook ${book.id} - missing title`);
return;
}
// Extract additional fields from API response
const author = metadata.authorName || metadata.author || null;
const narrator = metadata.narratorName || metadata.narrator || null;
const description = metadata.description || null;
const publishedYear = metadata.publishedYear
? parseInt(metadata.publishedYear)
: null;
const publisher = metadata.publisher || null;
const isbn = metadata.isbn || null;
const asin = metadata.asin || null;
const language = metadata.language || null;
const genres = metadata.genres || [];
const tags = book.tags || [];
const duration = book.media?.duration || null;
const numTracks = book.media?.numTracks || null;
const numChapters = book.media?.numChapters || null;
const size = book.size ? BigInt(book.size) : null;
const libraryId = book.libraryId || null;
// Get cover path - Audiobookshelf uses media.coverPath
const coverPath = book.media?.coverPath || null;
// Build full cover URL for download (needs to be absolute URL with base)
const coverUrl = coverPath ? `items/${book.id}/cover` : null;
// Series info - Audiobookshelf returns seriesName as a string like "Series Name #2"
// We need to parse this to extract the series name and sequence number
let series: string | null = null;
let seriesSequence: string | null = null;
if (metadata.seriesName && typeof metadata.seriesName === "string") {
const seriesStr = metadata.seriesName.trim();
// Try to extract sequence from patterns like:
// "Series Name #1", "Series Name #2", "Series Name Book 1", "Series Name, Book 1"
const sequencePatterns = [
/^(.+?)\s*#(\d+(?:\.\d+)?)\s*$/, // "Series Name #1" or "Series Name #1.5"
/^(.+?)\s*,?\s*Book\s*(\d+(?:\.\d+)?)\s*$/i, // "Series Name Book 1" or "Series Name, Book 1"
/^(.+?)\s*,?\s*Vol\.?\s*(\d+(?:\.\d+)?)\s*$/i, // "Series Name Vol 1" or "Series Name, Vol. 1"
/^(.+?)\s*\((\d+(?:\.\d+)?)\)\s*$/, // "Series Name (1)"
];
let matched = false;
for (const pattern of sequencePatterns) {
const match = seriesStr.match(pattern);
if (match) {
series = match[1].trim();
seriesSequence = match[2];
matched = true;
break;
}
}
// If no sequence pattern matched, use the whole string as series name
if (!matched && seriesStr) {
series = seriesStr;
seriesSequence = null;
}
}
// Fallback: check metadata.series array/object format
if (!series) {
if (Array.isArray(metadata.series) && metadata.series.length > 0) {
series = metadata.series[0]?.name || null;
seriesSequence =
metadata.series[0]?.sequence?.toString() || null;
} else if (
typeof metadata.series === "object" &&
metadata.series !== null
) {
series = metadata.series.name || null;
seriesSequence = metadata.series.sequence?.toString() || null;
}
}
// Log series info for debugging (only for first few books)
if (series) {
console.log(
` [Series] "${title}" -> "${series}" #${
seriesSequence || "?"
}`
);
}
// Download cover image if available - need to construct full URL
let localCoverPath: string | null = null;
if (coverUrl) {
// Get the Audiobookshelf base URL from the service
const fullCoverUrl = await this.getFullCoverUrl(coverUrl);
if (fullCoverUrl) {
localCoverPath = await this.downloadCover(
book.id,
fullCoverUrl
);
}
}
// Upsert to database
await prisma.audiobook.upsert({
where: { id: book.id },
create: {
id: book.id,
title,
author,
narrator,
description,
publishedYear,
publisher,
series,
seriesSequence,
duration,
numTracks,
numChapters,
size,
isbn,
asin,
language,
genres,
tags,
localCoverPath,
coverUrl,
audioUrl: book.id,
libraryId,
lastSyncedAt: new Date(),
},
update: {
title,
author,
narrator,
description,
publishedYear,
publisher,
series,
seriesSequence,
duration,
numTracks,
numChapters,
size,
isbn,
asin,
language,
genres,
tags,
localCoverPath: localCoverPath || undefined,
coverUrl,
audioUrl: book.id,
libraryId,
lastSyncedAt: new Date(),
},
});
}
/**
* Get full Audiobookshelf cover URL by prepending base URL
*/
private async getFullCoverUrl(
relativePath: string
): Promise<string | null> {
try {
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (settings?.audiobookshelfUrl) {
const baseUrl = settings.audiobookshelfUrl.replace(/\/$/, "");
return `${baseUrl}/api/${relativePath}`;
}
return null;
} catch (error: any) {
console.error(
"Failed to get Audiobookshelf base URL:",
error.message
);
return null;
}
}
/**
* Download a cover image and save it locally
*/
private async downloadCover(
audiobookId: string,
coverUrl: string
): Promise<string> {
try {
// Get API key for authentication
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings?.audiobookshelfApiKey) {
throw new Error("Audiobookshelf API key not configured");
}
const response = await fetch(coverUrl, {
headers: {
Authorization: `Bearer ${settings.audiobookshelfApiKey}`,
},
});
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
const buffer = await response.arrayBuffer();
const fileName = `${audiobookId}.jpg`;
const filePath = path.join(this.coverCacheDir, fileName);
await fs.writeFile(filePath, Buffer.from(buffer));
return filePath;
} catch (error: any) {
console.error(
`Failed to download cover for ${audiobookId}:`,
error.message
);
return null as any; // Return null if download fails
}
}
/**
* Get a single audiobook from cache or sync it
*/
async getAudiobook(audiobookId: string): Promise<any> {
// Try to get from database first
let audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
// If not in cache or stale (> 7 days), try to sync it
if (
!audiobook ||
audiobook.lastSyncedAt <
new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
) {
console.log(
`[AUDIOBOOK] Audiobook ${audiobookId} not cached or stale, syncing...`
);
try {
const book = await audiobookshelfService.getAudiobook(
audiobookId
);
await this.syncAudiobook(book);
audiobook = await prisma.audiobook.findUnique({
where: { id: audiobookId },
});
} catch (syncError: any) {
console.warn(
` Failed to sync audiobook ${audiobookId} from Audiobookshelf:`,
syncError.message
);
// If we have stale cached data, return it anyway
if (audiobook) {
console.log(
` Using stale cached data for ${audiobookId}`
);
} else {
// No cached data and sync failed - throw error
throw new Error(
`Audiobook not found in cache and sync failed: ${syncError.message}`
);
}
}
}
return audiobook;
}
/**
* Clean up old cached covers that are no longer in database
*/
async cleanupOrphanedCovers(): Promise<number> {
const audiobooks = await prisma.audiobook.findMany({
select: { localCoverPath: true },
});
const validCoverPaths = new Set(
audiobooks
.filter((a) => a.localCoverPath)
.map((a) => path.basename(a.localCoverPath!))
);
let deleted = 0;
const files = await fs.readdir(this.coverCacheDir);
for (const file of files) {
if (!validCoverPaths.has(file)) {
await fs.unlink(path.join(this.coverCacheDir, file));
deleted++;
console.log(` [DELETE] Deleted orphaned cover: ${file}`);
}
}
return deleted;
}
}
// Export singleton instance
export const audiobookCacheService = new AudiobookCacheService();
+335
View File
@@ -0,0 +1,335 @@
import axios, { AxiosInstance } from "axios";
import { getSystemSettings } from "../utils/systemSettings";
/**
* Audiobookshelf API Service
* Handles all interactions with the Audiobookshelf server
*/
class AudiobookshelfService {
private client: AxiosInstance | null = null;
private baseUrl: string | null = null;
private apiKey: string | null = null;
private initialized = false;
private podcastCache: { items: any[]; expiresAt: number } | null = null;
private readonly PODCAST_CACHE_TTL_MS = 5 * 60 * 1000;
private async ensureInitialized() {
if (this.initialized && this.client) return;
try {
// Try to get from database first
const settings = await getSystemSettings();
// Check if Audiobookshelf is explicitly disabled
if (settings && settings.audiobookshelfEnabled === false) {
throw new Error("Audiobookshelf is disabled in settings");
}
if (
settings?.audiobookshelfEnabled &&
settings?.audiobookshelfUrl &&
settings?.audiobookshelfApiKey
) {
this.baseUrl = settings.audiobookshelfUrl.replace(/\/$/, ""); // Remove trailing slash
this.apiKey = settings.audiobookshelfApiKey;
this.client = axios.create({
baseURL: this.baseUrl,
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
timeout: 30000, // 30 seconds for remote server
});
console.log("Audiobookshelf configured from database");
this.initialized = true;
return;
}
} catch (error: any) {
if (error.message === "Audiobookshelf is disabled in settings") {
throw error;
}
console.log(
" Could not load Audiobookshelf from database, checking .env"
);
}
// Fallback to .env
if (
process.env.AUDIOBOOKSHELF_URL &&
process.env.AUDIOBOOKSHELF_API_KEY
) {
this.baseUrl = process.env.AUDIOBOOKSHELF_URL.replace(/\/$/, "");
this.apiKey = process.env.AUDIOBOOKSHELF_API_KEY;
this.client = axios.create({
baseURL: this.baseUrl,
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
timeout: 30000, // 30 seconds for remote server
});
console.log("Audiobookshelf configured from .env");
this.initialized = true;
} else {
throw new Error("Audiobookshelf not configured");
}
}
/**
* Test connection to Audiobookshelf
*/
async ping(): Promise<boolean> {
try {
await this.ensureInitialized();
const response = await this.client!.get("/api/libraries");
return response.status === 200;
} catch (error) {
console.error("Audiobookshelf connection failed:", error);
return false;
}
}
/**
* Get all libraries from Audiobookshelf
*/
async getLibraries() {
await this.ensureInitialized();
const response = await this.client!.get("/api/libraries");
return response.data.libraries || [];
}
/**
* Get all audiobooks from a specific library
*/
async getLibraryItems(libraryId: string) {
await this.ensureInitialized();
const response = await this.client!.get(
`/api/libraries/${libraryId}/items`
);
return response.data.results || [];
}
/**
* Get all audiobooks from all libraries
*/
async getAllAudiobooks() {
await this.ensureInitialized();
const libraries = await this.getLibraries();
const allBooks: any[] = [];
for (const library of libraries) {
if (library.mediaType === "book") {
// Only get audiobook libraries
const items = await this.getLibraryItems(library.id);
// DEBUG: Log the structure of the first item with series
if (items.length > 0) {
const itemsWithSeries = items.filter((item: any) =>
item.media?.metadata?.series || item.media?.metadata?.seriesName
);
if (itemsWithSeries.length > 0) {
console.log(
"[AUDIOBOOKSHELF DEBUG] Sample item WITH series:",
JSON.stringify(itemsWithSeries[0], null, 2).substring(0, 2000)
);
} else {
console.log(
"[AUDIOBOOKSHELF DEBUG] No items with series found! Sample item:",
JSON.stringify(items[0], null, 2).substring(0, 1000)
);
}
}
allBooks.push(...items);
}
}
return allBooks;
}
/**
* Get all podcasts from all libraries
*/
async getAllPodcasts(forceRefresh = false) {
await this.ensureInitialized();
if (
!forceRefresh &&
this.podcastCache &&
this.podcastCache.expiresAt > Date.now()
) {
return this.podcastCache.items;
}
const libraries = await this.getLibraries();
const podcastLibraries = libraries.filter(
(library: any) => library.mediaType === "podcast"
);
const libraryResults = await Promise.all(
podcastLibraries.map(async (library: any) => {
try {
return await this.getLibraryItems(library.id);
} catch (error) {
console.error(
`Audiobookshelf: failed to load podcast library ${library.id}`,
error
);
return [];
}
})
);
const allPodcasts = libraryResults.flat();
this.podcastCache = {
items: allPodcasts,
expiresAt: Date.now() + this.PODCAST_CACHE_TTL_MS,
};
return allPodcasts;
}
/**
* Get a specific audiobook by ID
*/
async getAudiobook(audiobookId: string) {
await this.ensureInitialized();
const response = await this.client!.get(
`/api/items/${audiobookId}?expanded=1`
);
return response.data;
}
/**
* Get a specific podcast by ID (alias for getAudiobook since API is the same)
*/
async getPodcast(podcastId: string) {
return this.getAudiobook(podcastId);
}
/**
* Get user's progress for an audiobook
*/
async getProgress(audiobookId: string) {
await this.ensureInitialized();
const response = await this.client!.get(
`/api/me/progress/${audiobookId}`
);
return response.data;
}
/**
* Update user's progress for an audiobook
*/
async updateProgress(
audiobookId: string,
currentTime: number,
duration: number,
isFinished: boolean = false
) {
await this.ensureInitialized();
const response = await this.client!.patch(
`/api/me/progress/${audiobookId}`,
{
currentTime,
duration,
isFinished,
}
);
return response.data;
}
/**
* Get stream URL for an audiobook
*/
async getStreamUrl(audiobookId: string): Promise<string> {
await this.ensureInitialized();
return `${this.baseUrl}/api/items/${audiobookId}/play`;
}
/**
* Stream an audiobook with authentication
* Returns a readable stream that can be piped to the response
*/
async streamAudiobook(audiobookId: string, rangeHeader?: string) {
await this.ensureInitialized();
// First, get the audiobook to find the track file
const audiobook = await this.getAudiobook(audiobookId);
// Get the first track's content URL
const firstTrack = audiobook.media?.tracks?.[0];
if (!firstTrack || !firstTrack.contentUrl) {
throw new Error("No audio track found for this audiobook");
}
// Build request headers
const headers: Record<string, string> = {};
if (rangeHeader) {
headers["Range"] = rangeHeader;
}
// The contentUrl format is: /api/items/{id}/file/{ino}
const response = await this.client!.get(firstTrack.contentUrl, {
responseType: "stream",
timeout: 0, // No timeout for streaming
headers,
// Don't throw on 206 Partial Content
validateStatus: (status) => status >= 200 && status < 300,
});
return {
stream: response.data,
headers: response.headers,
status: response.status,
};
}
/**
* Stream a podcast episode with authentication
* For podcasts, we need to get a specific episode ID
*/
async streamPodcastEpisode(podcastId: string, episodeId: string) {
await this.ensureInitialized();
// Get the podcast to find the episode
const podcast = await this.getPodcast(podcastId);
const episode = podcast.media?.episodes?.find(
(ep: any) => ep.id === episodeId
);
if (!episode) {
throw new Error("Episode not found");
}
// Podcast episodes use audioTrack.contentUrl, not audioFile.contentUrl
const contentUrl =
episode.audioTrack?.contentUrl || episode.audioFile?.contentUrl;
if (!contentUrl) {
throw new Error("No audio file found for this episode");
}
const response = await this.client!.get(contentUrl, {
responseType: "stream",
timeout: 0,
});
return {
stream: response.data,
headers: response.headers,
};
}
/**
* Search audiobooks
*/
async searchAudiobooks(query: string) {
await this.ensureInitialized();
const response = await this.client!.get(
`/api/search/books?q=${encodeURIComponent(query)}`
);
return response.data.book || [];
}
}
export const audiobookshelfService = new AudiobookshelfService();
+67
View File
@@ -0,0 +1,67 @@
import axios from "axios";
import { redisClient } from "../utils/redis";
import { rateLimiter } from "./rateLimiter";
class CoverArtService {
private readonly baseUrl = "https://coverartarchive.org";
async getCoverArt(rgMbid: string): Promise<string | null> {
const cacheKey = `caa:${rgMbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached === "NOT_FOUND") return null; // Cached negative result
if (cached) return cached;
} catch (err) {
console.warn("Redis get error:", err);
}
try {
// Use rate limiter to prevent overwhelming Cover Art Archive
const response = await rateLimiter.execute("coverart", () =>
axios.get(`${this.baseUrl}/release-group/${rgMbid}`, {
timeout: 5000,
})
);
const images = response.data.images || [];
const frontImage =
images.find((img: any) => img.front) || images[0];
if (frontImage) {
const coverUrl =
frontImage.thumbnails?.large || frontImage.image;
try {
await redisClient.setEx(cacheKey, 2592000, coverUrl); // 30 days
} catch (err) {
console.warn("Redis set error:", err);
}
return coverUrl;
}
// No front image found - cache negative result
try {
await redisClient.setEx(cacheKey, 604800, "NOT_FOUND"); // 7 days
} catch (err) {
// Ignore
}
} catch (error: any) {
if (error.response?.status === 404) {
// No cover art available - cache the negative result
try {
await redisClient.setEx(cacheKey, 604800, "NOT_FOUND"); // 7 days
} catch (err) {
// Ignore
}
return null;
}
console.error(`Cover art error for ${rgMbid}:`, error.message);
}
return null;
}
}
export const coverArtService = new CoverArtService();
+75
View File
@@ -0,0 +1,75 @@
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { parseFile } from "music-metadata";
export class CoverArtExtractor {
private coverCachePath: string;
constructor(coverCachePath: string) {
this.coverCachePath = coverCachePath;
// Ensure cache directory exists
if (!fs.existsSync(this.coverCachePath)) {
fs.mkdirSync(this.coverCachePath, { recursive: true });
}
}
/**
* Extract cover art from audio file and save to cache
* Returns relative path to cached cover art, or null if none found
*/
async extractCoverArt(
audioFilePath: string,
albumId: string
): Promise<string | null> {
try {
// Check if already cached
const cacheFileName = `${albumId}.jpg`;
const cachePath = path.join(this.coverCachePath, cacheFileName);
if (fs.existsSync(cachePath)) {
return cacheFileName;
}
// Parse audio file metadata
const metadata = await parseFile(audioFilePath);
// Get embedded picture
const picture = metadata.common.picture?.[0];
if (!picture) {
return null;
}
// Save to cache
await fs.promises.writeFile(cachePath, picture.data);
console.log(
`[COVER-ART] Extracted cover art from ${path.basename(audioFilePath)}: ${cacheFileName}`
);
return cacheFileName;
} catch (err) {
console.error(
`[COVER-ART] Failed to extract from ${audioFilePath}:`,
err
);
return null;
}
}
/**
* Get cover art URL for album
* Returns relative path if available, or null
*/
async getCoverArtPath(albumId: string): Promise<string | null> {
const cacheFileName = `${albumId}.jpg`;
const cachePath = path.join(this.coverCachePath, cacheFileName);
if (fs.existsSync(cachePath)) {
return cacheFileName;
}
return null;
}
}
+390
View File
@@ -0,0 +1,390 @@
/**
* DataCacheService - Unified data access with consistent caching pattern
*
* Pattern: DB first -> Redis fallback -> API fetch -> save to both
*
* This ensures:
* - DB is the source of truth
* - Redis provides fast reads
* - API calls only happen when data doesn't exist
* - All fetched data is persisted for future use
*/
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
import { fanartService } from "./fanart";
import { deezerService } from "./deezer";
import { lastFmService } from "./lastfm";
import { coverArtService } from "./coverArt";
// Cache TTLs
const ARTIST_IMAGE_TTL = 7 * 24 * 60 * 60; // 7 days
const ALBUM_COVER_TTL = 30 * 24 * 60 * 60; // 30 days
const NEGATIVE_CACHE_TTL = 24 * 60 * 60; // 1 day for "not found" results
class DataCacheService {
/**
* Get artist hero image with unified caching
* Order: DB -> Redis -> Fanart.tv -> Deezer -> Last.fm -> save to both
*/
async getArtistImage(
artistId: string,
artistName: string,
mbid?: string | null
): Promise<string | null> {
const cacheKey = `hero:${artistId}`;
// 1. Check DB first (source of truth)
try {
const artist = await prisma.artist.findUnique({
where: { id: artistId },
select: { heroUrl: true },
});
if (artist?.heroUrl) {
// Also populate Redis for faster future reads
this.setRedisCache(cacheKey, artist.heroUrl, ARTIST_IMAGE_TTL);
return artist.heroUrl;
}
} catch (err) {
console.warn("[DataCache] DB lookup failed for artist:", artistId);
}
// 2. Check Redis cache
try {
const cached = await redisClient.get(cacheKey);
if (cached === "NOT_FOUND") return null; // Negative cache hit
if (cached) {
// Sync back to DB if Redis has it but DB doesn't
this.updateArtistHeroUrl(artistId, cached);
return cached;
}
} catch (err) {
// Redis errors are non-critical
}
// 3. Fetch from external APIs
const heroUrl = await this.fetchArtistImage(artistName, mbid);
// 4. Save to both DB and Redis
if (heroUrl) {
await this.updateArtistHeroUrl(artistId, heroUrl);
this.setRedisCache(cacheKey, heroUrl, ARTIST_IMAGE_TTL);
} else {
// Cache negative result to avoid repeated API calls
this.setRedisCache(cacheKey, "NOT_FOUND", NEGATIVE_CACHE_TTL);
}
return heroUrl;
}
/**
* Get album cover with unified caching
* Order: DB -> Redis -> Cover Art Archive -> save to both
*/
async getAlbumCover(
albumId: string,
rgMbid: string
): Promise<string | null> {
const cacheKey = `album-cover:${albumId}`;
// 1. Check DB first
try {
const album = await prisma.album.findUnique({
where: { id: albumId },
select: { coverUrl: true },
});
if (album?.coverUrl) {
this.setRedisCache(cacheKey, album.coverUrl, ALBUM_COVER_TTL);
return album.coverUrl;
}
} catch (err) {
console.warn("[DataCache] DB lookup failed for album:", albumId);
}
// 2. Check Redis cache
try {
const cached = await redisClient.get(cacheKey);
if (cached === "NOT_FOUND") return null;
if (cached) {
this.updateAlbumCoverUrl(albumId, cached);
return cached;
}
} catch (err) {
// Redis errors are non-critical
}
// 3. Fetch from Cover Art Archive
const coverUrl = await coverArtService.getCoverArt(rgMbid);
// 4. Save to both DB and Redis
if (coverUrl) {
await this.updateAlbumCoverUrl(albumId, coverUrl);
this.setRedisCache(cacheKey, coverUrl, ALBUM_COVER_TTL);
} else {
this.setRedisCache(cacheKey, "NOT_FOUND", NEGATIVE_CACHE_TTL);
}
return coverUrl;
}
/**
* Get track cover (uses album cover)
*/
async getTrackCover(
trackId: string,
albumId: string,
rgMbid?: string | null
): Promise<string | null> {
if (!rgMbid) {
// Try to get album's rgMbid from DB
const album = await prisma.album.findUnique({
where: { id: albumId },
select: { rgMbid: true, coverUrl: true },
});
if (album?.coverUrl) return album.coverUrl;
if (album?.rgMbid) rgMbid = album.rgMbid;
}
if (!rgMbid) return null;
return this.getAlbumCover(albumId, rgMbid);
}
/**
* Batch get artist images - for list views
* Only returns what's already cached, doesn't make API calls
*/
async getArtistImagesBatch(
artists: Array<{ id: string; heroUrl?: string | null }>
): Promise<Map<string, string | null>> {
const results = new Map<string, string | null>();
// First, use any heroUrls already in the data
for (const artist of artists) {
if (artist.heroUrl) {
results.set(artist.id, artist.heroUrl);
}
}
// For the rest, check Redis cache only (no API calls for list views)
const missingIds = artists
.filter((a) => !results.has(a.id))
.map((a) => a.id);
if (missingIds.length > 0) {
try {
const cacheKeys = missingIds.map((id) => `hero:${id}`);
const cached = await redisClient.mGet(cacheKeys);
missingIds.forEach((id, index) => {
const value = cached[index];
if (value && value !== "NOT_FOUND") {
results.set(id, value);
}
});
} catch (err) {
// Redis errors are non-critical
}
}
return results;
}
/**
* Batch get album covers - for list views
*/
async getAlbumCoversBatch(
albums: Array<{ id: string; coverUrl?: string | null }>
): Promise<Map<string, string | null>> {
const results = new Map<string, string | null>();
for (const album of albums) {
if (album.coverUrl) {
results.set(album.id, album.coverUrl);
}
}
const missingIds = albums
.filter((a) => !results.has(a.id))
.map((a) => a.id);
if (missingIds.length > 0) {
try {
const cacheKeys = missingIds.map((id) => `album-cover:${id}`);
const cached = await redisClient.mGet(cacheKeys);
missingIds.forEach((id, index) => {
const value = cached[index];
if (value && value !== "NOT_FOUND") {
results.set(id, value);
}
});
} catch (err) {
// Redis errors are non-critical
}
}
return results;
}
/**
* Fetch artist image from external APIs
* Order: Fanart.tv (if MBID) -> Deezer -> Last.fm
*/
private async fetchArtistImage(
artistName: string,
mbid?: string | null
): Promise<string | null> {
let heroUrl: string | null = null;
// Try Fanart.tv first if we have a valid MBID
if (mbid && !mbid.startsWith("temp-")) {
try {
heroUrl = await fanartService.getArtistImage(mbid);
if (heroUrl) {
console.log(`[DataCache] Got image from Fanart.tv for ${artistName}`);
return heroUrl;
}
} catch (err) {
// Fanart.tv failed, continue
}
}
// Try Deezer
try {
heroUrl = await deezerService.getArtistImage(artistName);
if (heroUrl) {
console.log(`[DataCache] Got image from Deezer for ${artistName}`);
return heroUrl;
}
} catch (err) {
// Deezer failed, continue
}
// Try Last.fm
try {
const validMbid = mbid && !mbid.startsWith("temp-") ? mbid : undefined;
const lastfmInfo = await lastFmService.getArtistInfo(artistName, validMbid);
if (lastfmInfo?.image && Array.isArray(lastfmInfo.image)) {
const largestImage =
lastfmInfo.image.find((img: any) => img.size === "extralarge" || img.size === "mega") ||
lastfmInfo.image[lastfmInfo.image.length - 1];
if (largestImage && largestImage["#text"]) {
// Filter out Last.fm placeholder images
const imageUrl = largestImage["#text"];
if (!imageUrl.includes("2a96cbd8b46e442fc41c2b86b821562f")) {
console.log(`[DataCache] Got image from Last.fm for ${artistName}`);
return imageUrl;
}
}
}
} catch (err) {
// Last.fm failed
}
console.log(`[DataCache] No image found for ${artistName}`);
return null;
}
/**
* Update artist heroUrl in database
*/
private async updateArtistHeroUrl(artistId: string, heroUrl: string): Promise<void> {
try {
await prisma.artist.update({
where: { id: artistId },
data: { heroUrl },
});
} catch (err) {
console.warn("[DataCache] Failed to update artist heroUrl:", err);
}
}
/**
* Update album coverUrl in database
*/
private async updateAlbumCoverUrl(albumId: string, coverUrl: string): Promise<void> {
try {
await prisma.album.update({
where: { id: albumId },
data: { coverUrl },
});
} catch (err) {
console.warn("[DataCache] Failed to update album coverUrl:", err);
}
}
/**
* Set Redis cache with error handling
*/
private async setRedisCache(key: string, value: string, ttl: number): Promise<void> {
try {
await redisClient.setEx(key, ttl, value);
} catch (err) {
// Redis errors are non-critical
}
}
/**
* Warm up Redis cache from database
* Called on server startup
*/
async warmupCache(): Promise<void> {
console.log("[DataCache] Warming up Redis cache from database...");
try {
// Warm up artist images
const artists = await prisma.artist.findMany({
where: { heroUrl: { not: null } },
select: { id: true, heroUrl: true },
});
let artistCount = 0;
for (const artist of artists) {
if (artist.heroUrl) {
await this.setRedisCache(`hero:${artist.id}`, artist.heroUrl, ARTIST_IMAGE_TTL);
artistCount++;
}
}
console.log(`[DataCache] Cached ${artistCount} artist images`);
// Warm up album covers
const albums = await prisma.album.findMany({
where: { coverUrl: { not: null } },
select: { id: true, coverUrl: true },
});
let albumCount = 0;
for (const album of albums) {
if (album.coverUrl) {
await this.setRedisCache(`album-cover:${album.id}`, album.coverUrl, ALBUM_COVER_TTL);
albumCount++;
}
}
console.log(`[DataCache] Cached ${albumCount} album covers`);
console.log("[DataCache] Cache warmup complete");
} catch (err) {
console.error("[DataCache] Cache warmup failed:", err);
}
}
}
export const dataCacheService = new DataCacheService();
+587
View File
@@ -0,0 +1,587 @@
import axios from "axios";
import { redisClient } from "../utils/redis";
/**
* Deezer Service
*
* Fetches images, previews, and public playlist data from Deezer.
* No authentication required - Deezer's API is completely public.
*/
const DEEZER_API = "https://api.deezer.com";
// ============================================
// Playlist Types
// ============================================
export interface DeezerTrack {
deezerId: string;
title: string;
artist: string;
artistId: string;
album: string;
albumId: string;
durationMs: number;
previewUrl: string | null;
coverUrl: string | null;
}
export interface DeezerPlaylist {
id: string;
title: string;
description: string | null;
creator: string;
imageUrl: string | null;
trackCount: number;
tracks: DeezerTrack[];
isPublic: boolean;
}
export interface DeezerPlaylistPreview {
id: string;
title: string;
description: string | null;
creator: string;
imageUrl: string | null;
trackCount: number;
fans: number;
}
export interface DeezerRadioStation {
id: string;
title: string;
description: string | null;
imageUrl: string | null;
type: "radio";
}
export interface DeezerGenre {
id: number;
name: string;
imageUrl: string | null;
}
export interface DeezerGenreWithRadios {
id: number;
name: string;
radios: DeezerRadioStation[];
}
// ============================================
// Service Class
// ============================================
class DeezerService {
private readonly cachePrefix = "deezer:";
private readonly cacheTTL = 86400; // 24 hours
/**
* Get cached value from Redis
*/
private async getCached(key: string): Promise<string | null> {
try {
return await redisClient.get(`${this.cachePrefix}${key}`);
} catch {
return null;
}
}
/**
* Set cached value in Redis
*/
private async setCache(key: string, value: string): Promise<void> {
try {
await redisClient.setex(`${this.cachePrefix}${key}`, this.cacheTTL, value);
} catch {
// Ignore cache errors
}
}
// ============================================
// Image & Preview Methods (existing functionality)
// ============================================
/**
* Search for an artist and get their image URL
*/
async getArtistImage(artistName: string): Promise<string | null> {
const cacheKey = `artist:${artistName.toLowerCase()}`;
const cached = await this.getCached(cacheKey);
if (cached) return cached === "null" ? null : cached;
try {
const response = await axios.get(`${DEEZER_API}/search/artist`, {
params: { q: artistName, limit: 1 },
timeout: 5000,
});
const artist = response.data?.data?.[0];
const imageUrl = artist?.picture_xl || artist?.picture_big || artist?.picture_medium || null;
await this.setCache(cacheKey, imageUrl || "null");
return imageUrl;
} catch (error: any) {
console.error(`Deezer artist image error for ${artistName}:`, error.message);
return null;
}
}
/**
* Search for an album and get its cover art URL
*/
async getAlbumCover(artistName: string, albumName: string): Promise<string | null> {
const cacheKey = `album:${artistName.toLowerCase()}:${albumName.toLowerCase()}`;
const cached = await this.getCached(cacheKey);
if (cached) return cached === "null" ? null : cached;
try {
const response = await axios.get(`${DEEZER_API}/search/album`, {
params: { q: `artist:"${artistName}" album:"${albumName}"`, limit: 5 },
timeout: 5000,
});
// Find the best match
const albums = response.data?.data || [];
let bestMatch = albums[0];
for (const album of albums) {
if (album.artist?.name?.toLowerCase() === artistName.toLowerCase() &&
album.title?.toLowerCase() === albumName.toLowerCase()) {
bestMatch = album;
break;
}
}
const coverUrl = bestMatch?.cover_xl || bestMatch?.cover_big || bestMatch?.cover_medium || null;
await this.setCache(cacheKey, coverUrl || "null");
return coverUrl;
} catch (error: any) {
console.error(`Deezer album cover error for ${artistName} - ${albumName}:`, error.message);
return null;
}
}
/**
* Get a preview URL for a track
*/
async getTrackPreview(artistName: string, trackName: string): Promise<string | null> {
const cacheKey = `preview:${artistName.toLowerCase()}:${trackName.toLowerCase()}`;
const cached = await this.getCached(cacheKey);
if (cached) return cached === "null" ? null : cached;
try {
const response = await axios.get(`${DEEZER_API}/search/track`, {
params: { q: `artist:"${artistName}" track:"${trackName}"`, limit: 1 },
timeout: 5000,
});
const track = response.data?.data?.[0];
const previewUrl = track?.preview || null;
await this.setCache(cacheKey, previewUrl || "null");
return previewUrl;
} catch (error: any) {
console.error(`Deezer track preview error for ${artistName} - ${trackName}:`, error.message);
return null;
}
}
// ============================================
// Playlist Methods (new functionality)
// ============================================
/**
* Parse a Deezer URL and extract the type and ID
*/
parseUrl(url: string): { type: "playlist" | "album" | "track"; id: string } | null {
const playlistMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/);
if (playlistMatch) {
return { type: "playlist", id: playlistMatch[1] };
}
const albumMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?album\/(\d+)/);
if (albumMatch) {
return { type: "album", id: albumMatch[1] };
}
const trackMatch = url.match(/deezer\.com\/(?:[a-z]{2}\/)?track\/(\d+)/);
if (trackMatch) {
return { type: "track", id: trackMatch[1] };
}
return null;
}
/**
* Fetch a playlist by ID
*/
async getPlaylist(playlistId: string): Promise<DeezerPlaylist | null> {
try {
console.log(`Deezer: Fetching playlist ${playlistId}...`);
const response = await axios.get(`${DEEZER_API}/playlist/${playlistId}`, {
timeout: 15000,
});
const data = response.data;
if (data.error) {
console.error("Deezer API error:", data.error);
return null;
}
const tracks: DeezerTrack[] = (data.tracks?.data || []).map((track: any) => ({
deezerId: String(track.id),
title: track.title || "Unknown",
artist: track.artist?.name || "Unknown Artist",
artistId: String(track.artist?.id || ""),
album: track.album?.title || "Unknown Album",
albumId: String(track.album?.id || ""),
durationMs: (track.duration || 0) * 1000,
previewUrl: track.preview || null,
coverUrl: track.album?.cover_medium || track.album?.cover || null,
}));
console.log(`Deezer: Fetched playlist "${data.title}" with ${tracks.length} tracks`);
return {
id: String(data.id),
title: data.title || "Unknown Playlist",
description: data.description || null,
creator: data.creator?.name || "Unknown",
imageUrl: data.picture_medium || data.picture || null,
trackCount: data.nb_tracks || tracks.length,
tracks,
isPublic: data.public ?? true,
};
} catch (error: any) {
console.error("Deezer playlist fetch error:", error.message);
return null;
}
}
/**
* Get chart playlists (top playlists)
*/
async getChartPlaylists(limit: number = 20): Promise<DeezerPlaylistPreview[]> {
try {
const response = await axios.get(`${DEEZER_API}/chart/0/playlists`, {
params: { limit },
timeout: 10000,
});
return (response.data?.data || []).map((playlist: any) => ({
id: String(playlist.id),
title: playlist.title || "Unknown",
description: null,
creator: playlist.user?.name || "Deezer",
imageUrl: playlist.picture_medium || playlist.picture || null,
trackCount: playlist.nb_tracks || 0,
fans: playlist.fans || 0,
}));
} catch (error: any) {
console.error("Deezer chart playlists error:", error.message);
return [];
}
}
/**
* Search for playlists
*/
async searchPlaylists(query: string, limit: number = 20): Promise<DeezerPlaylistPreview[]> {
try {
const response = await axios.get(`${DEEZER_API}/search/playlist`, {
params: { q: query, limit },
timeout: 10000,
});
return (response.data?.data || []).map((playlist: any) => ({
id: String(playlist.id),
title: playlist.title || "Unknown",
description: null,
creator: playlist.user?.name || "Unknown",
imageUrl: playlist.picture_medium || playlist.picture || null,
trackCount: playlist.nb_tracks || 0,
fans: 0,
}));
} catch (error: any) {
console.error("Deezer playlist search error:", error.message);
return [];
}
}
/**
* Get featured/curated playlists from multiple sources
* Combines chart playlists with popular genre-based searches
* Cached for 24 hours
*/
async getFeaturedPlaylists(limit: number = 50): Promise<DeezerPlaylistPreview[]> {
const cacheKey = `playlists:featured:${limit}`;
const cached = await this.getCached(cacheKey);
if (cached) {
console.log("Deezer: Returning cached featured playlists");
return JSON.parse(cached);
}
try {
const allPlaylists: DeezerPlaylistPreview[] = [];
const seenIds = new Set<string>();
// 1. Get chart playlists (max 99 available)
console.log("Deezer: Fetching chart playlists from API...");
const chartPlaylists = await this.getChartPlaylists(Math.min(limit, 99));
for (const p of chartPlaylists) {
if (!seenIds.has(p.id)) {
seenIds.add(p.id);
allPlaylists.push(p);
}
}
console.log(`Deezer: Got ${chartPlaylists.length} chart playlists`);
// 2. If we need more, search for popular genre playlists
if (allPlaylists.length < limit) {
const genres = ["pop", "rock", "hip hop", "electronic", "r&b", "indie", "jazz", "classical", "metal", "country"];
for (const genre of genres) {
if (allPlaylists.length >= limit) break;
try {
const genrePlaylists = await this.searchPlaylists(genre, 10);
for (const p of genrePlaylists) {
if (!seenIds.has(p.id) && allPlaylists.length < limit) {
seenIds.add(p.id);
allPlaylists.push(p);
}
}
} catch (e) {
// Continue with other genres
}
}
}
const result = allPlaylists.slice(0, limit);
console.log(`Deezer: Caching ${result.length} featured playlists`);
await this.setCache(cacheKey, JSON.stringify(result));
return result;
} catch (error: any) {
console.error("Deezer featured playlists error:", error.message);
return [];
}
}
/**
* Get genres/categories available on Deezer
*/
/**
* Get genres/categories available on Deezer
* Cached for 24 hours
*/
async getGenres(): Promise<Array<{ id: number; name: string; imageUrl: string | null }>> {
const cacheKey = "genres:all";
const cached = await this.getCached(cacheKey);
if (cached) {
console.log("Deezer: Returning cached genres");
return JSON.parse(cached);
}
try {
console.log("Deezer: Fetching genres from API...");
const response = await axios.get(`${DEEZER_API}/genre`, {
timeout: 10000,
});
const genres = (response.data?.data || [])
.filter((g: any) => g.id !== 0) // Skip "All" genre
.map((genre: any) => ({
id: genre.id,
name: genre.name,
imageUrl: genre.picture_medium || genre.picture || null,
}));
console.log(`Deezer: Caching ${genres.length} genres`);
await this.setCache(cacheKey, JSON.stringify(genres));
return genres;
} catch (error: any) {
console.error("Deezer genres error:", error.message);
return [];
}
}
/**
* Get playlists for a specific genre by searching
*/
async getGenrePlaylists(genreName: string, limit: number = 20): Promise<DeezerPlaylistPreview[]> {
return this.searchPlaylists(genreName, limit);
}
// ============================================
// Radio Methods
// ============================================
/**
* Get all radio stations (mood/theme based mixes)
* Cached for 24 hours
*/
async getRadioStations(): Promise<DeezerRadioStation[]> {
const cacheKey = "radio:stations";
const cached = await this.getCached(cacheKey);
if (cached) {
console.log("Deezer: Returning cached radio stations");
return JSON.parse(cached);
}
try {
console.log("Deezer: Fetching radio stations from API...");
const response = await axios.get(`${DEEZER_API}/radio`, {
timeout: 10000,
});
const stations = (response.data?.data || []).map((radio: any) => ({
id: String(radio.id),
title: radio.title || "Unknown",
description: null,
imageUrl: radio.picture_medium || radio.picture || null,
type: "radio" as const,
}));
console.log(`Deezer: Got ${stations.length} radio stations, caching...`);
await this.setCache(cacheKey, JSON.stringify(stations));
return stations;
} catch (error: any) {
console.error("Deezer radio stations error:", error.message);
return [];
}
}
/**
* Get radio stations organized by genre
*/
/**
* Get radio stations organized by genre
* Cached for 24 hours
*/
async getRadiosByGenre(): Promise<DeezerGenreWithRadios[]> {
const cacheKey = "radio:by-genre";
const cached = await this.getCached(cacheKey);
if (cached) {
console.log("Deezer: Returning cached radios by genre");
return JSON.parse(cached);
}
try {
console.log("Deezer: Fetching radios by genre from API...");
const response = await axios.get(`${DEEZER_API}/radio/genres`, {
timeout: 10000,
});
const genres = (response.data?.data || []).map((genre: any) => ({
id: genre.id,
name: genre.title || "Unknown",
radios: (genre.radios || []).map((radio: any) => ({
id: String(radio.id),
title: radio.title || "Unknown",
description: null,
imageUrl: radio.picture_medium || radio.picture || null,
type: "radio" as const,
})),
}));
console.log(`Deezer: Got ${genres.length} genre categories with radios, caching...`);
await this.setCache(cacheKey, JSON.stringify(genres));
return genres;
} catch (error: any) {
console.error("Deezer radios by genre error:", error.message);
return [];
}
}
/**
* Get tracks from a radio station (returns as DeezerPlaylist for consistency)
*/
async getRadioTracks(radioId: string): Promise<DeezerPlaylist | null> {
try {
console.log(`Deezer: Fetching radio ${radioId} tracks...`);
// First get radio info
const infoResponse = await axios.get(`${DEEZER_API}/radio/${radioId}`, {
timeout: 10000,
});
const radioInfo = infoResponse.data;
// Then get tracks
const tracksResponse = await axios.get(`${DEEZER_API}/radio/${radioId}/tracks`, {
params: { limit: 100 },
timeout: 15000,
});
const tracks: DeezerTrack[] = (tracksResponse.data?.data || []).map((track: any) => ({
deezerId: String(track.id),
title: track.title || "Unknown",
artist: track.artist?.name || "Unknown Artist",
artistId: String(track.artist?.id || ""),
album: track.album?.title || "Unknown Album",
albumId: String(track.album?.id || ""),
durationMs: (track.duration || 0) * 1000,
previewUrl: track.preview || null,
coverUrl: track.album?.cover_medium || track.album?.cover || null,
}));
console.log(`Deezer: Fetched radio "${radioInfo.title}" with ${tracks.length} tracks`);
return {
id: `radio-${radioId}`,
title: radioInfo.title || "Radio Station",
description: `Deezer Radio - ${radioInfo.title}`,
creator: "Deezer",
imageUrl: radioInfo.picture_medium || radioInfo.picture || null,
trackCount: tracks.length,
tracks,
isPublic: true,
};
} catch (error: any) {
console.error("Deezer radio tracks error:", error.message);
return null;
}
}
/**
* Get editorial/curated content for a specific genre
* Returns releases and playlists for that genre
*/
async getEditorialContent(genreId: number): Promise<{
playlists: DeezerPlaylistPreview[];
radios: DeezerRadioStation[];
}> {
try {
// Get genre-specific playlists via search
const genreResponse = await axios.get(`${DEEZER_API}/genre/${genreId}`, {
timeout: 10000,
});
const genreName = genreResponse.data?.name || "";
// Search for playlists with this genre
const playlists = genreName ? await this.searchPlaylists(genreName, 20) : [];
// Get radios for this genre from the genres endpoint
const radiosResponse = await axios.get(`${DEEZER_API}/radio/genres`, {
timeout: 10000,
});
const genreRadios = (radiosResponse.data?.data || []).find((g: any) => g.id === genreId);
const radios: DeezerRadioStation[] = (genreRadios?.radios || []).map((radio: any) => ({
id: String(radio.id),
title: radio.title || "Unknown",
description: null,
imageUrl: radio.picture_medium || radio.picture || null,
type: "radio" as const,
}));
return { playlists, radios };
} catch (error: any) {
console.error("Deezer editorial content error:", error.message);
return { playlists: [], radios: [] };
}
}
}
export const deezerService = new DeezerService();
File diff suppressed because it is too large Load Diff
+226
View File
@@ -0,0 +1,226 @@
import * as fs from "fs";
import * as path from "path";
/**
* Discovery Logger - Creates detailed log files for each discovery playlist generation
*/
class DiscoveryLogger {
private logDir: string;
private currentLogFile: string | null = null;
private currentStream: fs.WriteStream | null = null;
constructor() {
// Store logs in /app/logs/discovery (matches Dockerfile directory)
this.logDir = process.env.NODE_ENV === "production"
? "/app/logs/discovery"
: path.join(process.cwd(), "data", "logs", "discovery");
}
/**
* Start a new log file for a discovery generation
*/
start(userId: string, jobId?: number): string {
// Ensure log directory exists
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
// Create filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `discovery-${timestamp}-job${jobId || "manual"}.log`;
this.currentLogFile = path.join(this.logDir, filename);
// Open write stream
this.currentStream = fs.createWriteStream(this.currentLogFile, { flags: "a" });
// Write header
this.write("═".repeat(60));
this.write(`DISCOVERY WEEKLY GENERATION LOG`);
this.write(`Started: ${new Date().toISOString()}`);
this.write(`User ID: ${userId}`);
this.write(`Job ID: ${jobId || "manual"}`);
this.write("═".repeat(60));
this.write("");
return this.currentLogFile;
}
/**
* Write a line to the current log
*/
write(message: string, indent: number = 0): void {
const prefix = " ".repeat(indent);
const timestamp = new Date().toISOString().split("T")[1].split(".")[0];
const line = `[${timestamp}] ${prefix}${message}`;
// Write to file
if (this.currentStream) {
this.currentStream.write(line + "\n");
}
// Also write to console for real-time visibility
console.log(message);
}
/**
* Write a section header
*/
section(title: string): void {
this.write("");
this.write("─".repeat(50));
this.write(`> ${title}`);
this.write("─".repeat(50));
}
/**
* Write a success message
*/
success(message: string, indent: number = 0): void {
this.write(`${message}`, indent);
}
/**
* Write an error message
*/
error(message: string, indent: number = 0): void {
this.write(`${message}`, indent);
}
/**
* Write a warning message
*/
warn(message: string, indent: number = 0): void {
this.write(`[WARN] ${message}`, indent);
}
/**
* Write info message
*/
info(message: string, indent: number = 0): void {
this.write(` ${message}`, indent);
}
/**
* Write a table of key-value pairs
*/
table(data: Record<string, any>, indent: number = 1): void {
for (const [key, value] of Object.entries(data)) {
this.write(`${key}: ${value}`, indent);
}
}
/**
* Write a list of items
*/
list(items: string[], indent: number = 1): void {
for (const item of items) {
this.write(`${item}`, indent);
}
}
/**
* End the current log and close the stream
*/
end(success: boolean, summary?: string): void {
this.write("");
this.write("═".repeat(60));
this.write(`GENERATION ${success ? "COMPLETED" : "FAILED"}`);
if (summary) {
this.write(summary);
}
this.write(`Ended: ${new Date().toISOString()}`);
this.write("═".repeat(60));
if (this.currentStream) {
this.currentStream.end();
this.currentStream = null;
}
}
/**
* Get the path to the current log file
*/
getCurrentLogPath(): string | null {
return this.currentLogFile;
}
/**
* Get the most recent log file
*/
getLatestLog(): { path: string; content: string } | null {
if (!fs.existsSync(this.logDir)) {
return null;
}
const files = fs.readdirSync(this.logDir)
.filter(f => f.startsWith("discovery-") && f.endsWith(".log"))
.sort()
.reverse();
if (files.length === 0) {
return null;
}
const latestPath = path.join(this.logDir, files[0]);
const content = fs.readFileSync(latestPath, "utf-8");
return { path: latestPath, content };
}
/**
* Get all log files (most recent first)
*/
getAllLogs(): { filename: string; date: Date; size: number }[] {
if (!fs.existsSync(this.logDir)) {
return [];
}
return fs.readdirSync(this.logDir)
.filter(f => f.startsWith("discovery-") && f.endsWith(".log"))
.map(filename => {
const filePath = path.join(this.logDir, filename);
const stats = fs.statSync(filePath);
return {
filename,
date: stats.mtime,
size: stats.size
};
})
.sort((a, b) => b.date.getTime() - a.date.getTime());
}
/**
* Get a specific log file content
*/
getLogContent(filename: string): string | null {
const filePath = path.join(this.logDir, filename);
if (!fs.existsSync(filePath)) {
return null;
}
return fs.readFileSync(filePath, "utf-8");
}
/**
* Clean up old logs (keep last N)
*/
cleanup(keepCount: number = 20): number {
const logs = this.getAllLogs();
let deleted = 0;
for (let i = keepCount; i < logs.length; i++) {
const filePath = path.join(this.logDir, logs[i].filename);
fs.unlinkSync(filePath);
deleted++;
}
return deleted;
}
}
export const discoveryLogger = new DiscoveryLogger();
+658
View File
@@ -0,0 +1,658 @@
interface DownloadInfo {
downloadId: string;
albumTitle: string;
albumMbid: string;
artistName: string;
artistMbid?: string;
albumId?: number;
artistId?: number;
attempts: number;
startTime: number;
userId?: string;
tier?: string;
similarity?: number;
}
type UnavailableAlbumCallback = (info: {
albumTitle: string;
artistName: string;
albumMbid: string;
artistMbid?: string;
userId?: string;
tier?: string;
similarity?: number;
}) => Promise<void>;
class DownloadQueueManager {
private activeDownloads = new Map<string, DownloadInfo>();
private timeoutTimer: NodeJS.Timeout | null = null;
private cleanupInterval: NodeJS.Timeout | null = null;
private readonly TIMEOUT_MINUTES = 10; // Trigger scan after 10 minutes regardless
private readonly MAX_RETRY_ATTEMPTS = 3; // Max retries before giving up
private readonly STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - entries older than this are considered stale
private unavailableCallbacks: UnavailableAlbumCallback[] = [];
constructor() {
// Start periodic cleanup of stale downloads (every 5 minutes)
this.cleanupInterval = setInterval(() => {
this.cleanupStaleDownloads();
}, 5 * 60 * 1000);
}
/**
* Track a new download
*/
addDownload(
downloadId: string,
albumTitle: string,
albumMbid: string,
artistName: string,
albumId?: number,
artistId?: number,
options?: {
artistMbid?: string;
userId?: string;
tier?: string;
similarity?: number;
}
) {
const info: DownloadInfo = {
downloadId,
albumTitle,
albumMbid,
artistName,
artistMbid: options?.artistMbid,
albumId,
artistId,
attempts: 1,
startTime: Date.now(),
userId: options?.userId,
tier: options?.tier,
similarity: options?.similarity,
};
this.activeDownloads.set(downloadId, info);
console.log(
`[DOWNLOAD] Started: "${albumTitle}" by ${artistName} (${downloadId})`
);
console.log(` Album MBID: ${albumMbid}`);
console.log(` Active downloads: ${this.activeDownloads.size}`);
// Persist Lidarr download reference to download job for later status updates
this.linkDownloadJob(downloadId, albumMbid).catch((error) => {
console.error(` linkDownloadJob error:`, error);
});
// Start timeout on first download
if (this.activeDownloads.size === 1 && !this.timeoutTimer) {
this.startTimeout();
}
}
/**
* Register a callback to be notified when an album is unavailable
*/
onUnavailableAlbum(callback: UnavailableAlbumCallback) {
this.unavailableCallbacks.push(callback);
}
/**
* Clear all unavailable album callbacks
*/
clearUnavailableCallbacks() {
this.unavailableCallbacks = [];
}
/**
* Mark download as complete
*/
async completeDownload(downloadId: string, albumTitle: string) {
this.activeDownloads.delete(downloadId);
console.log(`Download complete: "${albumTitle}" (${downloadId})`);
console.log(` Remaining downloads: ${this.activeDownloads.size}`);
// If no more downloads, trigger refresh immediately
if (this.activeDownloads.size === 0) {
console.log(`⏰ All downloads complete! Starting refresh now...`);
this.clearTimeout();
this.triggerFullRefresh();
}
}
/**
* Mark download as failed and optionally retry
*/
async failDownload(downloadId: string, reason: string) {
const info = this.activeDownloads.get(downloadId);
if (!info) {
console.log(
` Download ${downloadId} not tracked, ignoring failure`
);
return;
}
console.log(` Download failed: "${info.albumTitle}" (${downloadId})`);
console.log(` Reason: ${reason}`);
console.log(` Attempt ${info.attempts}/${this.MAX_RETRY_ATTEMPTS}`);
// Check if we should retry
if (info.attempts < this.MAX_RETRY_ATTEMPTS) {
info.attempts++;
console.log(` Retrying download... (attempt ${info.attempts})`);
await this.retryDownload(info);
} else {
console.log(` ⛔ Max retry attempts reached, giving up`);
await this.cleanupFailedAlbum(info);
this.activeDownloads.delete(downloadId);
// Check if all downloads are done
if (this.activeDownloads.size === 0) {
console.log(
`⏰ All downloads finished (some failed). Starting refresh...`
);
this.clearTimeout();
this.triggerFullRefresh();
}
}
}
/**
* Retry a failed download by triggering Lidarr album search
*/
private async retryDownload(info: DownloadInfo) {
try {
if (!info.albumId) {
console.log(` No album ID, cannot retry`);
return;
}
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (
!settings.lidarrEnabled ||
!settings.lidarrUrl ||
!settings.lidarrApiKey
) {
console.log(` Lidarr not configured`);
return;
}
const axios = (await import("axios")).default;
// Trigger new album search
await axios.post(
`${settings.lidarrUrl}/api/v1/command`,
{
name: "AlbumSearch",
albumIds: [info.albumId],
},
{
headers: { "X-Api-Key": settings.lidarrApiKey },
timeout: 10000,
}
);
console.log(` Retry search triggered in Lidarr`);
} catch (error: any) {
console.log(` Failed to retry: ${error.message}`);
}
}
/**
* Clean up failed album from Lidarr and Discovery database
*/
private async cleanupFailedAlbum(info: DownloadInfo) {
try {
console.log(` Cleaning up failed album: ${info.albumTitle}`);
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (
!settings.lidarrEnabled ||
!settings.lidarrUrl ||
!settings.lidarrApiKey
) {
return;
}
const axios = (await import("axios")).default;
// Delete album from Lidarr
if (info.albumId) {
try {
await axios.delete(
`${settings.lidarrUrl}/api/v1/album/${info.albumId}`,
{
headers: { "X-Api-Key": settings.lidarrApiKey },
timeout: 10000,
}
);
console.log(` Removed album from Lidarr`);
} catch (error: any) {
console.log(` Failed to remove album: ${error.message}`);
}
}
// Check if artist has any other albums
if (info.artistId) {
try {
const artistResponse = await axios.get(
`${settings.lidarrUrl}/api/v1/artist/${info.artistId}`,
{
headers: { "X-Api-Key": settings.lidarrApiKey },
timeout: 10000,
}
);
const artist = artistResponse.data;
const monitoredAlbums =
artist.albums?.filter((a: any) => a.monitored) || [];
// If no other monitored albums, remove artist
if (monitoredAlbums.length === 0) {
await axios.delete(
`${settings.lidarrUrl}/api/v1/artist/${info.artistId}`,
{
params: { deleteFiles: false },
headers: { "X-Api-Key": settings.lidarrApiKey },
timeout: 10000,
}
);
console.log(
` Removed artist from Lidarr (no other albums)`
);
}
} catch (error: any) {
console.log(
` Failed to check/remove artist: ${error.message}`
);
}
}
// Mark as failed in Discovery database
const { prisma } = await import("../utils/db");
await prisma.discoveryAlbum.updateMany({
where: { albumTitle: info.albumTitle },
data: { status: "FAILED" },
});
console.log(` Marked as failed in database`);
// Notify callbacks about unavailable album
console.log(
` [NOTIFY] Notifying ${this.unavailableCallbacks.length} callbacks about unavailable album`
);
for (const callback of this.unavailableCallbacks) {
try {
await callback({
albumTitle: info.albumTitle,
artistName: info.artistName,
albumMbid: info.albumMbid,
artistMbid: info.artistMbid,
userId: info.userId,
tier: info.tier,
similarity: info.similarity,
});
} catch (error: any) {
console.log(` Callback error: ${error.message}`);
}
}
} catch (error: any) {
console.log(` Cleanup error: ${error.message}`);
}
}
/**
* Start timeout to trigger scan after X minutes even if downloads are still pending
*/
private startTimeout() {
const timeoutMs = this.TIMEOUT_MINUTES * 60 * 1000;
console.log(
`[TIMER] Starting ${this.TIMEOUT_MINUTES}-minute timeout for automatic scan`
);
this.timeoutTimer = setTimeout(() => {
if (this.activeDownloads.size > 0) {
console.log(
`\n Timeout reached! ${this.activeDownloads.size} downloads still pending.`
);
console.log(` These downloads never completed:`);
// Mark each pending download as failed to trigger callbacks
for (const [downloadId, info] of this.activeDownloads) {
console.log(
` - ${info.albumTitle} by ${info.artistName}`
);
// This will trigger the unavailable album callback
this.failDownload(
downloadId,
"Download timeout - never completed"
).catch((err) => {
console.error(
`Error failing download ${downloadId}:`,
err
);
});
}
console.log(
` Triggering scan anyway to process completed downloads...\n`
);
} else {
this.triggerFullRefresh();
}
}, timeoutMs);
}
/**
* Clear the timeout timer
*/
private clearTimeout() {
if (this.timeoutTimer) {
clearTimeout(this.timeoutTimer);
this.timeoutTimer = null;
}
}
/**
* Trigger full library refresh (Lidarr cleanup Lidify sync)
*/
private async triggerFullRefresh() {
try {
console.log("\n Starting full library refresh...\n");
// Step 1: Clear failed imports from Lidarr
console.log("[1/2] Checking for failed imports in Lidarr...");
await this.clearFailedLidarrImports();
// Step 2: Trigger Lidify library sync
console.log("[2/2] Triggering Lidify library sync...");
const lidifySuccess = await this.triggerLidifySync();
if (!lidifySuccess) {
console.error(" Lidify sync failed");
return;
}
console.log("Lidify sync started");
console.log(
"\n[SUCCESS] Full library refresh complete! New music should appear shortly.\n"
);
} catch (error) {
console.error(" Library refresh error:", error);
}
}
/**
* Clear failed imports from Lidarr queue
*/
private async clearFailedLidarrImports(): Promise<void> {
try {
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (!settings.lidarrEnabled || !settings.lidarrUrl) {
console.log(" Lidarr not configured, skipping");
return;
}
const axios = (await import("axios")).default;
// Get Lidarr API key
const apiKey = settings.lidarrApiKey;
if (!apiKey) {
console.log(" Lidarr API key not found, skipping");
return;
}
// Get queue
const response = await axios.get(
`${settings.lidarrUrl}/api/v1/queue`,
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
const queue = response.data.records || [];
// Find failed imports
const failed = queue.filter(
(item: any) =>
item.trackedDownloadStatus === "warning" ||
item.trackedDownloadStatus === "error" ||
item.status === "warning" ||
item.status === "failed"
);
if (failed.length === 0) {
console.log(" No failed imports found");
return;
}
console.log(` Found ${failed.length} failed import(s)`);
for (const item of failed) {
const artistName =
item.artist?.artistName || item.artist?.name || "Unknown";
const albumTitle =
item.album?.title || item.album?.name || "Unknown Album";
console.log(` ${artistName} - ${albumTitle}`);
try {
// Remove from queue, blocklist, and trigger search
await axios.delete(
`${settings.lidarrUrl}/api/v1/queue/${item.id}`,
{
params: {
removeFromClient: true,
blocklist: true,
},
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
// Trigger new search if album ID is available
if (item.album?.id) {
await axios.post(
`${settings.lidarrUrl}/api/v1/command`,
{
name: "AlbumSearch",
albumIds: [item.album.id],
},
{
headers: { "X-Api-Key": apiKey },
timeout: 10000,
}
);
console.log(
` → Blocklisted and searching for alternative`
);
} else {
console.log(
` → Blocklisted (no album ID for re-search)`
);
}
} catch (error: any) {
console.log(` Failed to process: ${error.message}`);
}
}
console.log(` Cleared ${failed.length} failed import(s)`);
} catch (error: any) {
console.log(` Failed to check Lidarr queue: ${error.message}`);
}
}
/**
* Trigger Lidify library sync
*/
private async triggerLidifySync(): Promise<boolean> {
try {
const { scanQueue } = await import("../workers/queues");
const { prisma } = await import("../utils/db");
console.log(" Starting library scan...");
// Get first user for scanning
const firstUser = await prisma.user.findFirst();
if (!firstUser) {
console.error(` No users found in database, cannot scan`);
return false;
}
// Trigger scan via queue
await scanQueue.add("scan", {
userId: firstUser.id,
source: "download-queue",
});
console.log("Library scan queued");
return true;
} catch (error: any) {
console.error("Lidify sync trigger error:", error.message);
return false;
}
}
/**
* Get current queue status
*/
getStatus() {
return {
activeDownloads: this.activeDownloads.size,
downloads: Array.from(this.activeDownloads.values()),
timeoutActive: this.timeoutTimer !== null,
};
}
/**
* Get the active downloads map (for checking if a download is being tracked)
*/
getActiveDownloads() {
return this.activeDownloads;
}
/**
* Manually trigger a full refresh (for testing or manual triggers)
*/
async manualRefresh() {
console.log("\n Manual refresh triggered...\n");
await this.triggerFullRefresh();
}
/**
* Clean up stale downloads that have been active for too long
* This prevents the activeDownloads Map from growing unbounded
*/
cleanupStaleDownloads(): number {
const now = Date.now();
let cleanedCount = 0;
for (const [downloadId, info] of this.activeDownloads) {
const age = now - info.startTime;
if (age > this.STALE_TIMEOUT_MS) {
console.log(
`[CLEANUP] Cleaning up stale download: "${
info.albumTitle
}" (${downloadId}) - age: ${Math.round(
age / 60000
)} minutes`
);
this.activeDownloads.delete(downloadId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
console.log(
`[CLEANUP] Cleaned up ${cleanedCount} stale download(s)`
);
}
return cleanedCount;
}
/**
* Shutdown the download queue manager (cleanup resources)
*/
shutdown() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.clearTimeout();
this.activeDownloads.clear();
console.log("Download queue manager shutdown");
}
/**
* Link Lidarr download IDs to download jobs (so we can mark them completed later)
*/
private async linkDownloadJob(downloadId: string, albumMbid: string) {
console.log(
` [LINK] Attempting to link download job for MBID: ${albumMbid}`
);
try {
const { prisma } = await import("../utils/db");
// Debug: Check if job exists
const existingJobs = await prisma.downloadJob.findMany({
where: { targetMbid: albumMbid },
select: {
id: true,
status: true,
lidarrRef: true,
targetMbid: true,
},
});
console.log(
` [LINK] Found ${existingJobs.length} job(s) with this MBID:`,
JSON.stringify(existingJobs, null, 2)
);
const result = await prisma.downloadJob.updateMany({
where: {
targetMbid: albumMbid,
status: { in: ["pending", "processing"] },
OR: [{ lidarrRef: null }, { lidarrRef: "" }],
},
data: {
lidarrRef: downloadId,
status: "processing",
},
});
if (result.count === 0) {
console.log(
` No matching download jobs found to link with Lidarr ID ${downloadId}`
);
console.log(
` This means either: no job exists, job already has lidarrRef, or status is not pending/processing`
);
} else {
console.log(
` Linked Lidarr download ${downloadId} to ${result.count} download job(s)`
);
}
} catch (error: any) {
console.error(
` Failed to persist Lidarr download link:`,
error.message
);
console.error(` Error details:`, error);
}
}
}
// Singleton instance
export const downloadQueueManager = new DownloadQueueManager();
+664
View File
@@ -0,0 +1,664 @@
/**
* Metadata Enrichment Service
*
* Enriches artist/album/track metadata using multiple sources:
* - MusicBrainz: MBIDs, release dates, track info
* - Last.fm: Genres, tags, similar artists, bio
* - Cover Art Archive: Album artwork
* - Discogs: Additional metadata (optional)
*
* Features:
* - Optional/opt-in (bandwidth intensive)
* - Rate limiting to respect API limits
* - Confidence scoring for matches
* - Manual override support
*/
import { prisma } from "../utils/db";
import { lastFmService } from "./lastfm";
import { musicBrainzService } from "./musicbrainz";
import { imageProviderService } from "./imageProvider";
export interface EnrichmentSettings {
enabled: boolean;
autoEnrichOnScan: boolean;
sources: {
musicbrainz: boolean;
lastfm: boolean;
coverArtArchive: boolean;
};
rateLimit: {
maxRequestsPerMinute: number;
respectApiLimits: boolean;
};
overwriteExisting: boolean;
matchingConfidence: "strict" | "moderate" | "loose";
}
export interface EnrichmentResult {
success: boolean;
itemsProcessed: number;
itemsEnriched: number;
itemsFailed: number;
errors: Array<{ item: string; error: string }>;
}
export interface ArtistEnrichmentData {
mbid?: string;
bio?: string;
genres?: string[];
tags?: string[];
similarArtists?: string[];
heroUrl?: string;
formed?: number;
confidence: number;
}
export interface AlbumEnrichmentData {
rgMbid?: string;
releaseDate?: Date;
albumType?: string;
genres?: string[];
tags?: string[];
label?: string;
coverUrl?: string;
trackCount?: number;
confidence: number;
}
export interface TrackEnrichmentData {
mbid?: string;
duration?: number;
genres?: string[];
lyrics?: string;
confidence: number;
}
export class EnrichmentService {
private defaultSettings: EnrichmentSettings = {
enabled: false, // Opt-in by default
autoEnrichOnScan: false,
sources: {
musicbrainz: true,
lastfm: true,
coverArtArchive: true,
},
rateLimit: {
maxRequestsPerMinute: 30,
respectApiLimits: true,
},
overwriteExisting: false,
matchingConfidence: "moderate",
};
private requestQueue: Array<() => Promise<any>> = [];
private isProcessingQueue = false;
/**
* Get enrichment settings for a user
*/
async getSettings(userId: string): Promise<EnrichmentSettings> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { enrichmentSettings: true },
});
if (user?.enrichmentSettings) {
// enrichmentSettings is already a JSON object from Prisma
let userSettings: any;
if (typeof user.enrichmentSettings === "string") {
userSettings = JSON.parse(user.enrichmentSettings);
} else {
userSettings = user.enrichmentSettings;
}
// IMPORTANT: Always merge with defaults to ensure all fields exist
return {
...this.defaultSettings,
...userSettings,
sources: {
...this.defaultSettings.sources,
...(userSettings.sources || {}),
},
rateLimit: {
...this.defaultSettings.rateLimit,
...(userSettings.rateLimit || {}),
},
};
}
return this.defaultSettings;
}
/**
* Update enrichment settings for a user
*/
async updateSettings(
userId: string,
settings: Partial<EnrichmentSettings>
): Promise<EnrichmentSettings> {
const current = await this.getSettings(userId);
const updated = { ...current, ...settings };
await prisma.user.update({
where: { id: userId },
data: {
enrichmentSettings: JSON.stringify(updated) as any,
},
});
return updated;
}
/**
* Enrich a single artist with metadata from multiple sources
*/
async enrichArtist(
artistId: string,
settings?: EnrichmentSettings
): Promise<ArtistEnrichmentData | null> {
const config = settings || this.defaultSettings;
if (!config.enabled) {
return null;
}
const artist = await prisma.artist.findUnique({
where: { id: artistId },
select: { id: true, name: true, mbid: true },
});
if (!artist) {
throw new Error(`Artist ${artistId} not found`);
}
console.log(`Enriching artist: ${artist.name}`);
const enrichmentData: ArtistEnrichmentData = {
confidence: 0,
};
// Step 1: Get/verify MBID from MusicBrainz
if (
config.sources.musicbrainz &&
(!artist.mbid || artist.mbid.startsWith("temp-"))
) {
try {
const mbResults = await musicBrainzService.searchArtist(
artist.name,
1
);
if (mbResults.length > 0) {
enrichmentData.mbid = mbResults[0].id;
enrichmentData.confidence += 0.4;
console.log(` Found MBID: ${enrichmentData.mbid}`);
}
} catch (error) {
console.error(` ✗ MusicBrainz lookup failed:`, error);
}
}
// Step 2: Get artist info from Last.fm
if (config.sources.lastfm) {
try {
const artistMbid = enrichmentData.mbid || artist.mbid;
const lastfmInfo = await lastFmService.getArtistInfo(
artist.name,
artistMbid && !artistMbid.startsWith("temp-")
? artistMbid
: undefined
);
if (lastfmInfo) {
enrichmentData.bio = lastfmInfo.bio?.summary;
enrichmentData.tags =
lastfmInfo.tags?.tag?.map((t: any) => t.name) || [];
enrichmentData.genres = enrichmentData.tags?.slice(0, 3); // Top 3 tags as genres
enrichmentData.confidence += 0.3;
console.log(
` Found Last.fm data: ${
enrichmentData.tags?.length || 0
} tags`
);
// Get similar artists
const similar = await lastFmService.getSimilarArtists(
artist.name,
"10"
);
enrichmentData.similarArtists = similar.map(
(a: any) => a.name
);
console.log(` Found ${similar.length} similar artists`);
}
} catch (error) {
console.error(
` ✗ Last.fm lookup failed:`,
error instanceof Error ? error.message : error
);
}
}
// Step 3: Get artist image from multiple sources (Deezer → Fanart → MusicBrainz → Last.fm)
try {
const artistMbid = enrichmentData.mbid || artist.mbid;
const imageResult = await imageProviderService.getArtistImage(
artist.name,
artistMbid && !artistMbid.startsWith("temp-")
? artistMbid
: undefined
);
if (imageResult) {
enrichmentData.heroUrl = imageResult.url;
enrichmentData.confidence += 0.2;
console.log(` Found artist image from ${imageResult.source}`);
}
} catch (error) {
console.error(
` ✗ Artist image lookup failed:`,
error instanceof Error ? error.message : error
);
}
console.log(
` Enrichment confidence: ${(
enrichmentData.confidence * 100
).toFixed(0)}%`
);
return enrichmentData;
}
/**
* Enrich a single album with metadata from multiple sources
*/
async enrichAlbum(
albumId: string,
settings?: EnrichmentSettings
): Promise<AlbumEnrichmentData | null> {
const config = settings || this.defaultSettings;
if (!config.enabled) {
return null;
}
const album = await prisma.album.findUnique({
where: { id: albumId },
include: {
artist: {
select: { name: true, mbid: true },
},
},
});
if (!album) {
throw new Error(`Album ${albumId} not found`);
}
console.log(
`[Enrichment] Processing album: ${album.artist.name} - ${album.title}`
);
const enrichmentData: AlbumEnrichmentData = {
confidence: 0,
};
// Step 1: Try to find MBID
if (config.sources.musicbrainz) {
try {
// If artist has MBID, search their discography
if (
album.artist.mbid &&
!album.artist.mbid.startsWith("temp-")
) {
const releaseGroups =
await musicBrainzService.getReleaseGroups(
album.artist.mbid,
["album", "ep"],
50
);
// Try to match by title
const match = releaseGroups.find(
(rg: any) =>
rg.title.toLowerCase() ===
album.title.toLowerCase() ||
rg.title.toLowerCase().replace(/[^a-z0-9]/g, "") ===
album.title
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
);
if (match) {
enrichmentData.rgMbid = match.id;
enrichmentData.albumType = match["primary-type"];
enrichmentData.releaseDate = match["first-release-date"]
? new Date(match["first-release-date"])
: undefined;
enrichmentData.confidence += 0.5;
console.log(` Found MBID: ${enrichmentData.rgMbid}`);
// Try to get label info from first release
try {
const rgDetails =
await musicBrainzService.getReleaseGroup(
match.id
);
if (rgDetails?.releases?.[0]?.id) {
const releaseId = rgDetails.releases[0].id;
const releaseInfo =
await musicBrainzService.getRelease(
releaseId
);
if (
releaseInfo?.["label-info"]?.[0]?.label
?.name
) {
enrichmentData.label =
releaseInfo["label-info"][0].label.name;
console.log(
` Found label: ${enrichmentData.label}`
);
}
}
} catch (error) {
console.log(`Could not fetch label info`);
}
}
}
} catch (error) {
console.error(` ✗ MusicBrainz lookup failed:`, error);
}
}
// Step 2: Get album info from Last.fm
if (config.sources.lastfm) {
try {
const lastfmInfo = await lastFmService.getAlbumInfo(
album.artist.name,
album.title,
enrichmentData.rgMbid
);
if (lastfmInfo) {
enrichmentData.tags =
lastfmInfo.tags?.tag?.map((t: any) => t.name) || [];
enrichmentData.genres = enrichmentData.tags?.slice(0, 3);
enrichmentData.trackCount =
lastfmInfo.tracks?.track?.length;
enrichmentData.confidence += 0.3;
console.log(
` Found Last.fm data: ${
enrichmentData.tags?.length || 0
} tags`
);
}
} catch (error) {
console.error(` ✗ Last.fm lookup failed:`, error);
}
}
// Step 3: Get cover art from multiple sources (Deezer → MusicBrainz → Fanart)
try {
const coverResult = await imageProviderService.getAlbumCover(
album.artist.name,
album.title,
enrichmentData.rgMbid
);
if (coverResult) {
enrichmentData.coverUrl = coverResult.url;
enrichmentData.confidence += 0.2;
console.log(` Found cover art from ${coverResult.source}`);
}
} catch (error) {
console.error(
` ✗ Cover art lookup failed:`,
error instanceof Error ? error.message : error
);
}
console.log(
` Enrichment confidence: ${(
enrichmentData.confidence * 100
).toFixed(0)}%`
);
return enrichmentData;
}
/**
* Apply enrichment data to an artist in the database
*/
async applyArtistEnrichment(
artistId: string,
data: ArtistEnrichmentData
): Promise<void> {
const updateData: any = {};
// Check if MBID is already in use by another artist
if (data.mbid) {
const existingArtist = await prisma.artist.findUnique({
where: { mbid: data.mbid },
select: { id: true, name: true },
});
if (existingArtist && existingArtist.id !== artistId) {
console.log(
`MBID ${data.mbid} already used by "${existingArtist.name}", skipping MBID update`
);
} else {
updateData.mbid = data.mbid;
}
}
if (data.bio) updateData.summary = data.bio;
if (data.heroUrl) updateData.heroUrl = data.heroUrl;
if (data.genres && data.genres.length > 0) {
updateData.genres = data.genres;
}
if (Object.keys(updateData).length > 0) {
await prisma.artist.update({
where: { id: artistId },
data: updateData,
});
console.log(
` Saved ${data.genres?.length || 0} genres for artist`
);
}
}
/**
* Apply enrichment data to an album in the database
*/
async applyAlbumEnrichment(
albumId: string,
data: AlbumEnrichmentData
): Promise<void> {
const updateData: any = {};
if (data.rgMbid) updateData.rgMbid = data.rgMbid;
if (data.coverUrl) updateData.coverUrl = data.coverUrl;
if (data.releaseDate) {
updateData.year = data.releaseDate.getFullYear();
}
if (data.label) updateData.label = data.label;
if (data.genres && data.genres.length > 0) {
updateData.genres = data.genres;
}
if (Object.keys(updateData).length > 0) {
await prisma.album.update({
where: { id: albumId },
data: updateData,
});
console.log(
` Saved album data: ${
data.genres?.length || 0
} genres, label: ${data.label || "none"}`
);
}
// Update OwnedAlbum table if MBID changed
if (data.rgMbid) {
const album = await prisma.album.findUnique({
where: { id: albumId },
select: { artistId: true },
});
if (album) {
await prisma.ownedAlbum.upsert({
where: {
artistId_rgMbid: {
artistId: album.artistId,
rgMbid: data.rgMbid,
},
},
create: {
artistId: album.artistId,
rgMbid: data.rgMbid,
source: "enrichment",
},
update: {},
});
}
}
}
/**
* Enrich entire library for a user
*/
async enrichLibrary(
userId: string,
onProgress?: (progress: {
current: number;
total: number;
item: string;
}) => void
): Promise<EnrichmentResult> {
const settings = await this.getSettings(userId);
if (!settings.enabled) {
throw new Error("Enrichment is not enabled for this user");
}
const result: EnrichmentResult = {
success: true,
itemsProcessed: 0,
itemsEnriched: 0,
itemsFailed: 0,
errors: [],
};
// Get all artists with their albums
const artists = await prisma.artist.findMany({
where: {
albums: {
some: {}, // Only artists with albums
},
},
select: {
id: true,
name: true,
albums: {
select: { id: true, title: true },
},
},
});
console.log(`Starting enrichment for ${artists.length} artists...`);
for (const artist of artists) {
try {
result.itemsProcessed++;
onProgress?.({
current: result.itemsProcessed,
total:
artists.length +
artists.reduce((sum, a) => sum + a.albums.length, 0),
item: `${artist.name}`,
});
// Enrich artist
const artistEnrichmentData = await this.enrichArtist(
artist.id,
settings
);
if (
artistEnrichmentData &&
artistEnrichmentData.confidence > 0.3
) {
await this.applyArtistEnrichment(
artist.id,
artistEnrichmentData
);
result.itemsEnriched++;
}
// Enrich all albums for this artist
for (const album of artist.albums) {
try {
result.itemsProcessed++;
onProgress?.({
current: result.itemsProcessed,
total:
artists.length +
artists.reduce(
(sum, a) => sum + a.albums.length,
0
),
item: `${artist.name} - ${album.title}`,
});
const albumEnrichmentData = await this.enrichAlbum(
album.id,
settings
);
if (
albumEnrichmentData &&
albumEnrichmentData.confidence > 0.3
) {
await this.applyAlbumEnrichment(
album.id,
albumEnrichmentData
);
result.itemsEnriched++;
}
// Rate limiting between albums
await new Promise((resolve) =>
setTimeout(resolve, 500)
);
} catch (error: any) {
result.itemsFailed++;
result.errors.push({
item: `${artist.name} - ${album.title}`,
error: error.message,
});
console.error(
` ✗ Failed to enrich ${artist.name} - ${album.title}:`,
error
);
}
}
// Rate limiting between artists
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error: any) {
result.itemsFailed++;
result.errors.push({
item: artist.name,
error: error.message,
});
console.error(` ✗ Failed to enrich ${artist.name}:`, error);
}
}
console.log(
`Enrichment complete: ${result.itemsEnriched}/${result.itemsProcessed} items enriched`
);
return result;
}
}
export const enrichmentService = new EnrichmentService();
+214
View File
@@ -0,0 +1,214 @@
import axios, { AxiosInstance } from "axios";
import { redisClient } from "../utils/redis";
import { getSystemSettings } from "../utils/systemSettings";
/**
* Fanart.tv API Service
*
* Provides high-quality artist images, album covers, and backgrounds
* API Docs: https://fanart.tv/api-docs/music-api/
*
* Free tier: 2 requests/second
* API key: Get one at https://fanart.tv/get-an-api-key/
*/
class FanartService {
private client: AxiosInstance;
private apiKey: string | null = null;
private initialized: boolean = false;
private noKeyWarningShown: boolean = false;
constructor() {
this.client = axios.create({
baseURL: "https://webservice.fanart.tv/v3",
timeout: 10000,
headers: {
"User-Agent": "Lidify/1.0",
},
});
}
/**
* Ensure service is initialized with API key from database or .env
*/
private async ensureInitialized() {
if (this.initialized) return;
try {
// Try to get from database first
const settings = await getSystemSettings();
if (settings?.fanartEnabled && settings?.fanartApiKey) {
this.apiKey = settings.fanartApiKey;
console.log("Fanart.tv configured from database");
this.initialized = true;
return;
}
} catch (error) {
// Silently continue to check .env
}
// Fallback to .env
if (process.env.FANART_API_KEY) {
this.apiKey = process.env.FANART_API_KEY;
console.log("Fanart.tv configured from .env");
}
// Note: Not logging "not configured" here - it's optional and logs are spammy
this.initialized = true;
}
/**
* Get artist images (background, thumbnail, logo)
* Returns the highest quality artist image available
*/
async getArtistImage(mbid: string): Promise<string | null> {
await this.ensureInitialized();
// Early exit if no API key - don't log every time (reduces log spam)
if (!this.apiKey) {
return null;
}
// Check cache first
const cacheKey = `fanart:artist:${mbid}`;
try {
if (redisClient.isOpen) {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(` Fanart.tv: Using cached image`);
return cached;
}
}
} catch (error) {
// Redis errors are non-critical
}
try {
console.log(` Fetching from Fanart.tv...`);
const response = await this.client.get(`/music/${mbid}`, {
params: { api_key: this.apiKey },
});
const data = response.data;
// Priority: artistbackground > artistthumb > hdmusiclogo
let imageUrl: string | null = null;
if (data.artistbackground && data.artistbackground.length > 0) {
let rawUrl = data.artistbackground[0].url;
// If it's just a filename, construct the full URL
if (rawUrl && !rawUrl.startsWith("http")) {
rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistbackground/${rawUrl}`;
console.log(
` Fanart.tv: Constructed full URL from filename`
);
}
imageUrl = rawUrl;
console.log(` Fanart.tv: Found artist background`);
} else if (data.artistthumb && data.artistthumb.length > 0) {
let rawUrl = data.artistthumb[0].url;
// If it's just a filename, construct the full URL
if (rawUrl && !rawUrl.startsWith("http")) {
rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/artistthumb/${rawUrl}`;
console.log(
` Fanart.tv: Constructed full URL from filename`
);
}
imageUrl = rawUrl;
console.log(` Fanart.tv: Found artist thumbnail`);
} else if (data.hdmusiclogo && data.hdmusiclogo.length > 0) {
let rawUrl = data.hdmusiclogo[0].url;
// If it's just a filename, construct the full URL
if (rawUrl && !rawUrl.startsWith("http")) {
rawUrl = `https://assets.fanart.tv/fanart/music/${mbid}/hdmusiclogo/${rawUrl}`;
console.log(
` Fanart.tv: Constructed full URL from filename`
);
}
imageUrl = rawUrl;
console.log(` Fanart.tv: Found HD logo`);
}
// Cache for 7 days
if (imageUrl && redisClient.isOpen) {
try {
await redisClient.setEx(
cacheKey,
7 * 24 * 60 * 60,
imageUrl
);
} catch (error) {
// Redis errors are non-critical
}
}
return imageUrl;
} catch (error: any) {
if (error.response?.status === 404) {
console.log(`Fanart.tv: No images found`);
} else {
console.error(` Fanart.tv error:`, error.message);
}
return null;
}
}
/**
* Get album cover art
*/
async getAlbumCover(mbid: string): Promise<string | null> {
await this.ensureInitialized();
if (!this.apiKey) return null;
const cacheKey = `fanart:album:${mbid}`;
try {
if (redisClient.isOpen) {
const cached = await redisClient.get(cacheKey);
if (cached) return cached;
}
} catch (error) {
// Redis errors are non-critical
}
try {
const response = await this.client.get(`/music/albums/${mbid}`, {
params: { api_key: this.apiKey },
});
const data = response.data;
let imageUrl: string | null = null;
if (data.albums && data.albums[mbid]) {
const album = data.albums[mbid];
if (album.albumcover && album.albumcover.length > 0) {
imageUrl = album.albumcover[0].url;
} else if (album.cdart && album.cdart.length > 0) {
imageUrl = album.cdart[0].url;
}
}
if (imageUrl && redisClient.isOpen) {
try {
await redisClient.setEx(
cacheKey,
7 * 24 * 60 * 60,
imageUrl
);
} catch (error) {
// Redis errors are non-critical
}
}
return imageUrl;
} catch (error) {
return null;
}
}
}
export const fanartService = new FanartService();
+175
View File
@@ -0,0 +1,175 @@
import * as fs from "fs";
import * as path from "path";
import { prisma } from "../utils/db";
import { config } from "../config";
import PQueue from "p-queue";
export interface ValidationResult {
tracksChecked: number;
tracksRemoved: number;
tracksMissing: string[]; // IDs of missing tracks
duration: number;
}
export class FileValidatorService {
private validationQueue = new PQueue({ concurrency: 50 });
/**
* Validate all tracks in the library and remove missing files
*/
async validateLibrary(): Promise<ValidationResult> {
const startTime = Date.now();
const result: ValidationResult = {
tracksChecked: 0,
tracksRemoved: 0,
tracksMissing: [],
duration: 0,
};
console.log("[FileValidator] Starting library validation...");
// Get all tracks from the database
const tracks = await prisma.track.findMany({
select: {
id: true,
filePath: true,
title: true,
},
});
console.log(
`[FileValidator] Found ${tracks.length} tracks to validate`
);
// Check each track's file existence
const missingTrackIds: string[] = [];
for (const track of tracks) {
await this.validationQueue.add(async () => {
try {
const absolutePath = path.normalize(
path.join(config.music.musicPath, track.filePath)
);
// Prevent path traversal attacks
if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) {
console.warn(
`[FileValidator] Path traversal attempt detected: ${track.filePath}`
);
missingTrackIds.push(track.id);
result.tracksChecked++;
return;
}
const exists = await this.fileExists(absolutePath);
if (!exists) {
console.log(
`[FileValidator] Missing file: ${track.filePath} (${track.title})`
);
missingTrackIds.push(track.id);
}
result.tracksChecked++;
// Log progress every 100 tracks
if (result.tracksChecked % 100 === 0) {
console.log(
`[FileValidator] Progress: ${result.tracksChecked}/${tracks.length} tracks checked, ${missingTrackIds.length} missing`
);
}
} catch (err: any) {
console.error(
`[FileValidator] Error checking ${track.filePath}:`,
err.message
);
}
});
}
await this.validationQueue.onIdle();
result.tracksMissing = missingTrackIds;
// Remove missing tracks from database
if (missingTrackIds.length > 0) {
console.log(
`[FileValidator] Removing ${missingTrackIds.length} missing tracks from database...`
);
await prisma.track.deleteMany({
where: {
id: { in: missingTrackIds },
},
});
result.tracksRemoved = missingTrackIds.length;
}
result.duration = Date.now() - startTime;
console.log(
`[FileValidator] Validation complete: ${result.tracksChecked} checked, ${result.tracksRemoved} removed (${result.duration}ms)`
);
return result;
}
/**
* Check if a file exists (async)
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* Validate a single track and remove if missing
*/
async validateTrack(trackId: string): Promise<boolean> {
const track = await prisma.track.findUnique({
where: { id: trackId },
select: {
id: true,
filePath: true,
title: true,
},
});
if (!track) {
return false;
}
const absolutePath = path.normalize(
path.join(config.music.musicPath, track.filePath)
);
// Prevent path traversal attacks
if (!absolutePath.startsWith(path.normalize(config.music.musicPath))) {
console.warn(
`[FileValidator] Path traversal attempt detected: ${track.filePath}`
);
return false;
}
const exists = await this.fileExists(absolutePath);
if (!exists) {
console.log(
`[FileValidator] Track file missing, removing from DB: ${track.title}`
);
await prisma.track.delete({
where: { id: trackId },
});
return false;
}
return true;
}
}
export const fileValidator = new FileValidatorService();
+421
View File
@@ -0,0 +1,421 @@
/**
* Image Provider Service
*
* Tries multiple sources for high-quality artist/album artwork:
* 1. Deezer (most reliable, high quality)
* 2. Fanart.tv (excellent quality, requires API key)
* 3. MusicBrainz Cover Art Archive (good quality)
* 4. Last.fm (fallback, often missing)
*/
import axios from "axios";
export interface ImageSearchOptions {
preferredSize?: "small" | "medium" | "large" | "extralarge" | "mega";
timeout?: number;
}
export interface ImageResult {
url: string;
source: "deezer" | "fanart" | "musicbrainz" | "lastfm" | "spotify";
size?: string;
}
export class ImageProviderService {
private readonly FANART_API_KEY = process.env.FANART_API_KEY;
private readonly DEEZER_API_URL = "https://api.deezer.com";
private readonly FANART_API_URL = "https://webservice.fanart.tv/v3";
/**
* Get artist image from multiple sources with fallback chain
*/
async getArtistImage(
artistName: string,
mbid?: string,
options: ImageSearchOptions = {}
): Promise<ImageResult | null> {
const { timeout = 5000 } = options;
console.log(`[IMAGE] Searching for artist image: ${artistName}`);
// Try Deezer first (most reliable)
try {
const deezerImage = await this.getArtistImageFromDeezer(
artistName,
timeout
);
if (deezerImage) {
console.log(` Found image from Deezer`);
return deezerImage;
}
} catch (error) {
console.log(
` Deezer failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
// Try Fanart.tv if we have API key and MBID
if (this.FANART_API_KEY && mbid) {
try {
const fanartImage = await this.getArtistImageFromFanart(
mbid,
timeout
);
if (fanartImage) {
console.log(` Found image from Fanart.tv`);
return fanartImage;
}
} catch (error) {
console.log(
`Fanart.tv failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
// Try MusicBrainz/Cover Art Archive if we have MBID
if (mbid) {
try {
const mbImage = await this.getArtistImageFromMusicBrainz(
mbid,
timeout
);
if (mbImage) {
console.log(` Found image from MusicBrainz`);
return mbImage;
}
} catch (error) {
console.log(
`MusicBrainz failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
console.log(` ✗ No artist image found from any source`);
return null;
}
/**
* Get album cover from multiple sources with fallback chain
*/
async getAlbumCover(
artistName: string,
albumTitle: string,
rgMbid?: string,
options: ImageSearchOptions = {}
): Promise<ImageResult | null> {
const { timeout = 5000 } = options;
console.log(
`[IMAGE] Searching for album cover: ${artistName} - ${albumTitle}`
);
// Try Deezer first (most reliable)
try {
const deezerCover = await this.getAlbumCoverFromDeezer(
artistName,
albumTitle,
timeout
);
if (deezerCover) {
console.log(` Found cover from Deezer`);
return deezerCover;
}
} catch (error) {
console.log(
` Deezer failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
// Try MusicBrainz Cover Art Archive if we have MBID
if (rgMbid) {
try {
const mbCover = await this.getAlbumCoverFromMusicBrainz(
rgMbid,
timeout
);
if (mbCover) {
console.log(` Found cover from MusicBrainz`);
return mbCover;
}
} catch (error) {
console.log(
`MusicBrainz failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
// Try Fanart.tv if we have API key and MBID
if (this.FANART_API_KEY && rgMbid) {
try {
const fanartCover = await this.getAlbumCoverFromFanart(
rgMbid,
timeout
);
if (fanartCover) {
console.log(` Found cover from Fanart.tv`);
return fanartCover;
}
} catch (error) {
console.log(
`Fanart.tv failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
}
console.log(` ✗ No album cover found from any source`);
return null;
}
/**
* Search Deezer for artist image
*/
private async getArtistImageFromDeezer(
artistName: string,
timeout: number
): Promise<ImageResult | null> {
const response = await axios.get(
`${this.DEEZER_API_URL}/search/artist`,
{
params: { q: artistName, limit: 1 },
timeout,
}
);
if (response.data.data && response.data.data.length > 0) {
const artist = response.data.data[0];
// Deezer provides: picture, picture_small, picture_medium, picture_big, picture_xl
const imageUrl =
artist.picture_xl || artist.picture_big || artist.picture;
if (imageUrl) {
return {
url: imageUrl,
source: "deezer",
size: "xl",
};
}
}
return null;
}
/**
* Search Deezer for album cover
*/
private async getAlbumCoverFromDeezer(
artistName: string,
albumTitle: string,
timeout: number
): Promise<ImageResult | null> {
const response = await axios.get(
`${this.DEEZER_API_URL}/search/album`,
{
params: {
q: `artist:"${artistName}" album:"${albumTitle}"`,
limit: 5,
},
timeout,
}
);
if (response.data.data && response.data.data.length > 0) {
// Try to find exact match first
let album = response.data.data.find(
(a: any) =>
a.title.toLowerCase() === albumTitle.toLowerCase() &&
a.artist.name.toLowerCase() === artistName.toLowerCase()
);
// Fall back to first result
if (!album) {
album = response.data.data[0];
}
// Deezer provides: cover, cover_small, cover_medium, cover_big, cover_xl
const coverUrl = album.cover_xl || album.cover_big || album.cover;
if (coverUrl) {
return {
url: coverUrl,
source: "deezer",
size: "xl",
};
}
}
return null;
}
/**
* Get artist image from Fanart.tv
*/
private async getArtistImageFromFanart(
mbid: string,
timeout: number
): Promise<ImageResult | null> {
if (!this.FANART_API_KEY) {
return null;
}
const response = await axios.get(
`${this.FANART_API_URL}/music/${mbid}`,
{
params: { api_key: this.FANART_API_KEY },
timeout,
}
);
// Fanart.tv provides multiple image types, prefer artistthumb
const images =
response.data.artistthumb ||
response.data.musicbanner ||
response.data.hdmusiclogo;
if (images && images.length > 0) {
return {
url: images[0].url,
source: "fanart",
};
}
return null;
}
/**
* Get album cover from Fanart.tv
*/
private async getAlbumCoverFromFanart(
rgMbid: string,
timeout: number
): Promise<ImageResult | null> {
if (!this.FANART_API_KEY) {
return null;
}
const response = await axios.get(
`${this.FANART_API_URL}/music/albums/${rgMbid}`,
{
params: { api_key: this.FANART_API_KEY },
timeout,
}
);
// Prefer albumcover, fall back to cdart
const covers =
response.data.albums?.[rgMbid]?.albumcover ||
response.data.albums?.[rgMbid]?.cdart;
if (covers && covers.length > 0) {
return {
url: covers[0].url,
source: "fanart",
};
}
return null;
}
/**
* Get artist image from MusicBrainz (via relationships)
*/
private async getArtistImageFromMusicBrainz(
mbid: string,
timeout: number
): Promise<ImageResult | null> {
// MusicBrainz doesn't have direct artist images, but we can check for image relationships
// This is a placeholder - in practice, we'd need to parse relationships
return null;
}
/**
* Get album cover from MusicBrainz Cover Art Archive
*/
private async getAlbumCoverFromMusicBrainz(
rgMbid: string,
timeout: number
): Promise<ImageResult | null> {
try {
const response = await axios.get(
`https://coverartarchive.org/release-group/${rgMbid}`,
{
timeout,
validateStatus: (status) => status === 200,
}
);
if (response.data.images && response.data.images.length > 0) {
// Find front cover
const frontCover =
response.data.images.find(
(img: any) => img.front === true
) || response.data.images[0];
return {
url: frontCover.image,
source: "musicbrainz",
};
}
} catch (error) {
// 404 is expected if no cover art exists
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
return null;
}
/**
* Get artist image from Last.fm (fallback only - often unreliable)
*/
async getArtistImageFromLastFm(
artistName: string,
mbid?: string
): Promise<ImageResult | null> {
try {
const { lastFmService } = await import("./lastfm");
const artistInfo = await lastFmService.getArtistInfo(
artistName,
mbid
);
if (artistInfo?.image) {
const megaImage = artistInfo.image.find(
(img: any) => img.size === "mega"
);
const largeImage = artistInfo.image.find(
(img: any) => img.size === "extralarge"
);
const image = megaImage || largeImage;
if (image?.["#text"]) {
return {
url: image["#text"],
source: "lastfm",
size: image.size,
};
}
}
} catch (error) {
console.log(
`Last.fm failed: ${
error instanceof Error ? error.message : "Unknown error"
}`
);
}
return null;
}
}
export const imageProviderService = new ImageProviderService();
+339
View File
@@ -0,0 +1,339 @@
import axios, { AxiosInstance } from "axios";
import { redisClient } from "../utils/redis";
interface ItunesPodcast {
collectionId: number;
collectionName: string;
artistName: string;
artworkUrl600?: string;
artworkUrl100?: string;
feedUrl: string;
genres: string[];
trackCount?: number;
country?: string;
primaryGenreName?: string;
contentAdvisoryRating?: string;
collectionViewUrl?: string;
}
class ItunesService {
private client: AxiosInstance;
private lastRequestTime = 0;
private readonly RATE_LIMIT_MS = 3000; // 20 requests per minute = 3 seconds between requests
constructor() {
this.client = axios.create({
baseURL: "https://itunes.apple.com",
timeout: 10000,
});
}
private async rateLimit() {
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
if (timeSinceLastRequest < this.RATE_LIMIT_MS) {
const delay = this.RATE_LIMIT_MS - timeSinceLastRequest;
await new Promise((resolve) => setTimeout(resolve, delay));
}
this.lastRequestTime = Date.now();
}
private async cachedRequest<T>(
cacheKey: string,
requestFn: () => Promise<T>,
ttlSeconds = 604800 // 7 days default
): Promise<T> {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
await this.rateLimit();
const data = await requestFn();
try {
await redisClient.setEx(cacheKey, ttlSeconds, JSON.stringify(data));
} catch (err) {
console.warn("Redis set error:", err);
}
return data;
}
/**
* Search for podcasts by term
*/
async searchPodcasts(
term: string,
limit = 20
): Promise<ItunesPodcast[]> {
const cacheKey = `itunes:search:${term}:${limit}`;
return this.cachedRequest(
cacheKey,
async () => {
const response = await this.client.get("/search", {
params: {
term,
media: "podcast",
entity: "podcast",
limit,
},
});
return response.data.results || [];
},
2592000 // 30 days - podcast catalog changes slowly
);
}
/**
* Lookup podcast by iTunes ID
*/
async getPodcastById(podcastId: number): Promise<ItunesPodcast | null> {
const cacheKey = `itunes:podcast:${podcastId}`;
return this.cachedRequest(
cacheKey,
async () => {
const response = await this.client.get("/lookup", {
params: {
id: podcastId,
entity: "podcast",
},
});
const results = response.data.results || [];
return results.length > 0 ? results[0] : null;
},
2592000 // 30 days
);
}
/**
* Extract primary keywords from podcast title/description for "similar podcasts" search
*/
extractSearchKeywords(
title: string,
description?: string,
author?: string
): string[] {
const commonWords = new Set([
"the",
"a",
"an",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
"by",
"from",
"up",
"about",
"into",
"through",
"during",
"before",
"after",
"above",
"below",
"between",
"under",
"again",
"further",
"then",
"once",
"here",
"there",
"when",
"where",
"why",
"how",
"all",
"both",
"each",
"few",
"more",
"most",
"other",
"some",
"such",
"no",
"nor",
"not",
"only",
"own",
"same",
"so",
"than",
"too",
"very",
"can",
"will",
"just",
"should",
"now",
"podcast",
"show",
"episode",
"episodes",
]);
// Combine title and description
const text = [title, description || "", author || ""]
.join(" ")
.toLowerCase()
.replace(/[^\w\s]/g, " "); // Remove punctuation
// Extract words, filter common words, and count occurrences
const words = text.split(/\s+/).filter((word) => {
return (
word.length > 3 &&
!commonWords.has(word) &&
!/^\d+$/.test(word) // Remove pure numbers
);
});
// Count word frequency
const wordCount = new Map<string, number>();
words.forEach((word) => {
wordCount.set(word, (wordCount.get(word) || 0) + 1);
});
// Sort by frequency and take top 5
const topWords = Array.from(wordCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([word]) => word);
return topWords;
}
/**
* Get similar podcasts based on keywords extracted from title/description
* This provides a "similar podcasts" feature similar to Last.fm for music
*/
async getSimilarPodcasts(
title: string,
description?: string,
author?: string,
limit = 10
): Promise<ItunesPodcast[]> {
const keywords = this.extractSearchKeywords(title, description, author);
if (keywords.length === 0) {
console.log(
"No keywords extracted for similar podcast search, falling back to title"
);
return this.searchPodcasts(title, limit);
}
console.log(
` Searching for similar podcasts using keywords: ${keywords.join(", ")}`
);
// Search using the top keyword (most relevant)
const searchTerm = keywords[0];
const cacheKey = `itunes:similar:${searchTerm}:${limit}`;
return this.cachedRequest(
cacheKey,
async () => {
const results = await this.searchPodcasts(searchTerm, limit * 2);
// Filter out the original podcast (by title similarity)
const titleLower = title.toLowerCase();
const filtered = results.filter((podcast) => {
const podcastTitleLower = podcast.collectionName.toLowerCase();
// Exclude if titles are very similar (likely same podcast)
return !podcastTitleLower.includes(titleLower.slice(0, 20));
});
return filtered.slice(0, limit);
},
2592000 // 30 days
);
}
/**
* Get top podcasts by genre using iTunes RSS feeds
* Note: iTunes Search API doesn't support genreId filtering, but RSS feeds do
*/
async getTopPodcastsByGenre(
genreId: number,
limit = 20
): Promise<ItunesPodcast[]> {
console.log(`[iTunes SERVICE] getTopPodcastsByGenre called with genre=${genreId}, limit=${limit}`);
const cacheKey = `itunes:genre:${genreId}:${limit}`;
console.log(`[iTunes SERVICE] Cache key: ${cacheKey}`);
const result = await this.cachedRequest(
cacheKey,
async () => {
try {
console.log(`[iTunes] Fetching genre ${genreId} from RSS feed...`);
// Use iTunes RSS feed for top podcasts by genre
const response = await this.client.get(
`/us/rss/toppodcasts/genre=${genreId}/limit=${limit}/json`
);
console.log(`[iTunes] Response status: ${response.status}`);
console.log(`[iTunes] Has feed data: ${!!response.data?.feed}`);
console.log(`[iTunes] Entries count: ${response.data?.feed?.entry?.length || 0}`);
const entries = response.data?.feed?.entry || [];
// If only one entry, it might not be an array
const entriesArray = Array.isArray(entries) ? entries : [entries];
console.log(`[iTunes] Processing ${entriesArray.length} entries`);
// Convert RSS feed format to our podcast format
const podcasts = entriesArray.map((entry: any) => {
const podcast = {
collectionId: parseInt(entry.id?.attributes?.["im:id"] || "0", 10),
collectionName: entry["im:name"]?.label || entry.title?.label?.split(" - ")[0] || "Unknown",
artistName: entry["im:artist"]?.label || entry.title?.label?.split(" - ")[1] || "Unknown",
artworkUrl600: entry["im:image"]?.find((img: any) => img.attributes?.height === "170")?.label,
artworkUrl100: entry["im:image"]?.find((img: any) => img.attributes?.height === "60")?.label,
feedUrl: "", // RSS feed doesn't include feed URL
genres: entry.category ? [entry.category.attributes?.label] : [],
trackCount: 0,
primaryGenreName: entry.category?.attributes?.label,
collectionViewUrl: entry.link?.attributes?.href,
};
console.log(`[iTunes] Mapped podcast: ${podcast.collectionName} (ID: ${podcast.collectionId})`);
return podcast;
}).filter((p: any) => p.collectionId > 0); // Filter out invalid entries
console.log(`[iTunes] Returning ${podcasts.length} valid podcasts`);
return podcasts;
} catch (error) {
console.error(`[iTunes] ERROR in requestFn:`, error);
return [];
}
},
2592000 // 30 days
);
console.log(`[iTunes SERVICE] cachedRequest returned ${result.length} podcasts`);
return result;
}
}
export const itunesService = new ItunesService();
+947
View File
@@ -0,0 +1,947 @@
import axios, { AxiosInstance } from "axios";
import * as fuzz from "fuzzball";
import { config } from "../config";
import { redisClient } from "../utils/redis";
import { getSystemSettings } from "../utils/systemSettings";
import { fanartService } from "./fanart";
import { deezerService } from "./deezer";
import { rateLimiter } from "./rateLimiter";
interface SimilarArtist {
name: string;
mbid?: string;
match: number; // 0-1 similarity score
url: string;
}
class LastFmService {
private client: AxiosInstance;
private apiKey: string;
private initialized = false;
constructor() {
// Initial value from .env (for backwards compatibility)
this.apiKey = config.lastfm.apiKey;
this.client = axios.create({
baseURL: "https://ws.audioscrobbler.com/2.0/",
timeout: 10000,
});
}
private async ensureInitialized() {
if (this.initialized) return;
// Priority: 1) User settings from DB, 2) env var, 3) default app key
try {
const { getSystemSettings } = await import(
"../utils/systemSettings"
);
const settings = await getSystemSettings();
if (settings?.lastfmApiKey) {
this.apiKey = settings.lastfmApiKey;
console.log("Last.fm configured from user settings");
} else if (this.apiKey) {
console.log("Last.fm configured (default app key)");
}
} catch (err) {
// DB not ready yet, use default/env key
if (this.apiKey) {
console.log("Last.fm configured (default app key)");
}
}
if (!this.apiKey) {
console.warn("Last.fm API key not available");
}
this.initialized = true;
}
private async request<T = any>(params: Record<string, any>) {
await this.ensureInitialized();
const response = await rateLimiter.execute("lastfm", () =>
this.client.get<T>("/", { params })
);
return response.data;
}
async getSimilarArtists(
artistMbid: string,
artistName: string,
limit = 30
): Promise<SimilarArtist[]> {
const cacheKey = `lastfm:similar:${artistMbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "artist.getSimilar",
mbid: artistMbid,
api_key: this.apiKey,
format: "json",
limit,
});
const similar = data.similarartists?.artist || [];
const results: SimilarArtist[] = similar.map((artist: any) => ({
name: artist.name,
mbid: artist.mbid || undefined,
match: parseFloat(artist.match) || 0,
url: artist.url,
}));
// Cache for 7 days
try {
await redisClient.setEx(
cacheKey,
604800,
JSON.stringify(results)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return results;
} catch (error: any) {
// If MBID lookup fails, try by name
if (
error.response?.status === 404 ||
error.response?.data?.error === 6
) {
console.log(
`Artist MBID not found on Last.fm, trying name search: ${artistName}`
);
return this.getSimilarArtistsByName(artistName, limit);
}
console.error(`Last.fm error for ${artistName}:`, error);
return [];
}
}
private async getSimilarArtistsByName(
artistName: string,
limit = 30
): Promise<SimilarArtist[]> {
const cacheKey = `lastfm:similar:name:${artistName}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "artist.getSimilar",
artist: artistName,
api_key: this.apiKey,
format: "json",
limit,
});
const similar = data.similarartists?.artist || [];
const results: SimilarArtist[] = similar.map((artist: any) => ({
name: artist.name,
mbid: artist.mbid || undefined,
match: parseFloat(artist.match) || 0,
url: artist.url,
}));
// Cache for 7 days
try {
await redisClient.setEx(
cacheKey,
604800,
JSON.stringify(results)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return results;
} catch (error) {
console.error(`Last.fm error for ${artistName}:`, error);
return [];
}
}
async getAlbumInfo(artistName: string, albumName: string) {
const cacheKey = `lastfm:album:${artistName}:${albumName}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "album.getInfo",
artist: artistName,
album: albumName,
api_key: this.apiKey,
format: "json",
});
const album = data.album;
// Cache for 30 days
try {
await redisClient.setEx(
cacheKey,
2592000,
JSON.stringify(album)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return album;
} catch (error) {
console.error(`Last.fm album info error for ${albumName}:`, error);
return null;
}
}
async getTopAlbumsByTag(tag: string, limit = 20) {
const cacheKey = `lastfm:tag:albums:${tag}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "tag.getTopAlbums",
tag,
api_key: this.apiKey,
format: "json",
limit,
});
const albums = data.albums?.album || [];
// Cache for 7 days
try {
await redisClient.setEx(
cacheKey,
604800,
JSON.stringify(albums)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return albums;
} catch (error) {
console.error(`Last.fm tag albums error for ${tag}:`, error);
return [];
}
}
async getSimilarTracks(artistName: string, trackName: string, limit = 20) {
const cacheKey = `lastfm:similar:track:${artistName}:${trackName}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "track.getSimilar",
artist: artistName,
track: trackName,
api_key: this.apiKey,
format: "json",
limit,
});
const tracks = data.similartracks?.track || [];
// Cache for 7 days
try {
await redisClient.setEx(
cacheKey,
604800,
JSON.stringify(tracks)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return tracks;
} catch (error) {
console.error(
`Last.fm similar tracks error for ${trackName}:`,
error
);
return [];
}
}
async getArtistTopTracks(
artistMbid: string,
artistName: string,
limit = 10
) {
const cacheKey = `lastfm:toptracks:${artistMbid || artistName}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const params: any = {
method: "artist.getTopTracks",
api_key: this.apiKey,
format: "json",
limit,
};
if (artistMbid) {
params.mbid = artistMbid;
} else {
params.artist = artistName;
}
const data = await this.request(params);
const tracks = data.toptracks?.track || [];
// Cache for 7 days
try {
await redisClient.setEx(
cacheKey,
604800,
JSON.stringify(tracks)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return tracks;
} catch (error) {
console.error(`Last.fm top tracks error for ${artistName}:`, error);
return [];
}
}
async getArtistTopAlbums(
artistMbid: string,
artistName: string,
limit = 10
) {
const cacheKey = `lastfm:topalbums:${artistMbid || artistName}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const params: any = {
method: "artist.getTopAlbums",
api_key: this.apiKey,
format: "json",
limit,
};
if (artistMbid) {
params.mbid = artistMbid;
} else {
params.artist = artistName;
}
const data = await this.request(params);
const albums = data.topalbums?.album || [];
// Cache for 7 days
try {
await redisClient.setEx(
cacheKey,
604800,
JSON.stringify(albums)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return albums;
} catch (error) {
console.error(`Last.fm top albums error for ${artistName}:`, error);
return [];
}
}
/**
* Get detailed artist info including real images
*/
async getArtistInfo(artistName: string, mbid?: string) {
try {
const params: any = {
method: "artist.getinfo",
api_key: this.apiKey,
format: "json",
};
if (mbid) {
params.mbid = mbid;
} else {
params.artist = artistName;
}
const data = await this.request(params);
return data.artist;
} catch (error) {
console.error(
`Last.fm artist info error for ${artistName}:`,
error
);
return null;
}
}
/**
* Extract the best available image from Last.fm image array
*/
public getBestImage(imageArray: any[]): string | null {
if (!imageArray || !Array.isArray(imageArray)) {
return null;
}
// Try extralarge first, then large, then medium, then small
const image =
imageArray.find((img: any) => img.size === "extralarge")?.[
"#text"
] ||
imageArray.find((img: any) => img.size === "large")?.["#text"] ||
imageArray.find((img: any) => img.size === "medium")?.["#text"] ||
imageArray.find((img: any) => img.size === "small")?.["#text"];
// Filter out empty/placeholder images
if (
!image ||
image === "" ||
image.includes("2a96cbd8b46e442fc41c2b86b821562f")
) {
return null;
}
return image;
}
private isInvalidArtistName(name?: string | null) {
if (!name) return true;
const normalized = name.trim().toLowerCase();
return (
normalized.length === 0 ||
normalized === "unknown" ||
normalized === "various artists"
);
}
private normalizeName(name: string | undefined | null) {
return (name || "").trim().toLowerCase();
}
private normalizeKey(name: string | undefined | null) {
return this.normalizeName(name)
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]/g, "");
}
private getArtistKey(artist: any) {
return (
artist.mbid || this.normalizeKey(artist.name) || artist.url || ""
);
}
private isDuplicateArtist(existing: any[], candidate: any) {
const candidateKey = this.getArtistKey(candidate);
if (!candidateKey) {
return true;
}
for (const entry of existing) {
const entryKey = this.getArtistKey(entry);
if (entryKey && entryKey === candidateKey) {
return true;
}
const nameSimilarity = fuzz.ratio(
this.normalizeName(entry.name),
this.normalizeName(candidate.name)
);
if (nameSimilarity >= 95) {
return true;
}
}
return false;
}
private isStandaloneSingle(albumName: string, trackName: string) {
const albumLower = albumName.toLowerCase();
const trackLower = trackName.toLowerCase();
return (
albumLower === trackLower ||
albumLower === `${trackLower} - single` ||
albumLower.endsWith(" - single") ||
albumLower.endsWith(" (single)")
);
}
private async buildArtistSearchResult(artist: any, enrich: boolean) {
const baseResult = {
type: "music",
id: artist.mbid || artist.name,
name: artist.name,
listeners: parseInt(artist.listeners || "0", 10),
url: artist.url,
image: this.getBestImage(artist.image),
mbid: artist.mbid,
bio: null,
tags: [] as string[],
};
if (!enrich) {
return baseResult;
}
const [info, fanartImage, deezerImage] = await Promise.all([
this.getArtistInfo(artist.name, artist.mbid),
artist.mbid
? fanartService
.getArtistImage(artist.mbid)
.catch(() => null as string | null)
: Promise.resolve<string | null>(null),
deezerService
.getArtistImage(artist.name)
.catch(() => null as string | null),
]);
const resolvedImage =
fanartImage ||
deezerImage ||
(info ? this.getBestImage(info.image) : null) ||
baseResult.image;
return {
...baseResult,
image: resolvedImage,
bio: info?.bio?.summary || info?.bio?.content || null,
tags: info?.tags?.tag?.map((t: any) => t.name) || [],
};
}
private async buildTrackSearchResult(track: any, enrich: boolean) {
if (this.isInvalidArtistName(track.artist)) {
return null;
}
const baseResult = {
type: "track",
id: track.mbid || `${track.artist}-${track.name}`,
name: track.name,
artist: track.artist,
album: track.album || null,
listeners: parseInt(track.listeners || "0", 10),
url: track.url,
image: this.getBestImage(track.image),
mbid: track.mbid,
};
if (!enrich) {
return baseResult;
}
const trackInfo = await this.getTrackInfo(track.artist, track.name);
let albumName = trackInfo?.album?.title || baseResult.album;
let albumArt =
this.getBestImage(trackInfo?.album?.image) || baseResult.image;
if (albumName && this.isStandaloneSingle(albumName, track.name)) {
return null;
}
if (!albumArt) {
albumArt = await deezerService
.getArtistImage(track.artist)
.catch(() => null as string | null);
}
return {
...baseResult,
album: albumName,
image: albumArt,
};
}
/**
* Search for artists on Last.fm and fetch their detailed info with images
*/
async searchArtists(query: string, limit = 20) {
try {
const data = await this.request({
method: "artist.search",
artist: query,
api_key: this.apiKey,
format: "json",
limit,
});
const artists = data.results?.artistmatches?.artist || [];
console.log(
`\n [LAST.FM SEARCH] Found ${artists.length} artists (before filtering)`
);
const queryLower = query.toLowerCase().trim();
const words = queryLower.split(/\s+/).filter(Boolean);
const minWordMatches =
words.length <= 2
? words.length
: Math.max(1, words.length - 1);
const escapeRegex = (text: string) =>
text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const wordMatchers = words.map((word) => {
if (word.length <= 2) {
return (candidate: string) => candidate.includes(word);
}
const regex = new RegExp(`\\b${escapeRegex(word)}\\b`);
return (candidate: string) => regex.test(candidate);
});
const scoredArtists = artists
.map((artist: any) => {
const normalizedName = this.normalizeName(artist.name);
const similarity = fuzz.token_set_ratio(
queryLower,
normalizedName
);
const listeners = parseInt(artist.listeners || "0", 10);
const hasMbid = Boolean(artist.mbid);
const wordMatches = wordMatchers.filter((matcher) =>
matcher(normalizedName)
).length;
return {
artist,
similarity,
listeners,
hasMbid,
wordMatches,
};
})
.filter(({ similarity, wordMatches }) => {
if (!queryLower) return true;
return similarity >= 50 || wordMatches >= minWordMatches;
})
.sort((a, b) => {
return (
Number(b.hasMbid) - Number(a.hasMbid) ||
b.wordMatches - a.wordMatches ||
b.listeners - a.listeners ||
b.similarity - a.similarity
);
});
const uniqueArtists: any[] = [];
for (const entry of scoredArtists) {
const artist = entry.artist;
if (this.isDuplicateArtist(uniqueArtists, artist)) {
continue;
}
uniqueArtists.push(artist);
}
if (uniqueArtists.length > 0 && uniqueArtists.length < limit) {
const primaryArtist = uniqueArtists[0];
try {
const fallbackSimilar = await this.getSimilarArtists(
primaryArtist.mbid || "",
primaryArtist.name,
limit * 2
);
for (const similar of fallbackSimilar) {
if (uniqueArtists.length >= limit) {
break;
}
const candidate = {
name: similar.name,
mbid: similar.mbid,
listeners: 0,
url: similar.url,
image: [],
};
if (this.isDuplicateArtist(uniqueArtists, candidate)) {
continue;
}
uniqueArtists.push(candidate);
}
} catch (error) {
console.warn(
"[LAST.FM SEARCH] Similar artist fallback failed:",
error
);
}
}
const limitedArtists = uniqueArtists.slice(0, limit);
console.log(
` → Filtered to ${limitedArtists.length} relevant matches (limit: ${limit})`
);
const enrichmentCount = Math.min(5, limitedArtists.length);
const [enriched, fast] = await Promise.all([
Promise.all(
limitedArtists
.slice(0, enrichmentCount)
.map((artist: any) =>
this.buildArtistSearchResult(artist, true)
)
),
Promise.all(
limitedArtists
.slice(enrichmentCount)
.map((artist: any) =>
this.buildArtistSearchResult(artist, false)
)
),
]);
return [...enriched, ...fast].filter(Boolean);
} catch (error) {
console.error("Last.fm artist search error:", error);
return [];
}
}
/**
* Search for tracks on Last.fm
*/
async searchTracks(query: string, limit = 20) {
try {
const data = await this.request({
method: "track.search",
track: query,
api_key: this.apiKey,
format: "json",
limit,
});
const tracks = data.results?.trackmatches?.track || [];
console.log(
`\n [LAST.FM TRACK SEARCH] Found ${tracks.length} tracks`
);
const validTracks = tracks.filter(
(track: any) => !this.isInvalidArtistName(track.artist)
);
const limitedTracks = validTracks.slice(0, limit);
const enrichmentCount = Math.min(8, limitedTracks.length);
const [enriched, fast] = await Promise.all([
Promise.all(
limitedTracks
.slice(0, enrichmentCount)
.map((track: any) =>
this.buildTrackSearchResult(track, true)
)
),
Promise.all(
limitedTracks
.slice(enrichmentCount)
.map((track: any) =>
this.buildTrackSearchResult(track, false)
)
),
]);
return [...enriched, ...fast].filter(Boolean);
} catch (error) {
console.error("Last.fm track search error:", error);
return [];
}
}
/**
* Get detailed track info including album
*/
async getTrackInfo(artistName: string, trackName: string) {
try {
const data = await this.request({
method: "track.getInfo",
artist: artistName,
track: trackName,
api_key: this.apiKey,
format: "json",
});
return data.track;
} catch (error) {
// Don't log errors for track info (many tracks don't have full info)
return null;
}
}
/**
* Get popular artists from Last.fm charts
*/
async getTopChartArtists(limit = 20) {
await this.ensureInitialized();
// Return empty if no API key configured
if (!this.apiKey) {
console.warn(
"Last.fm: Cannot fetch chart artists - no API key configured"
);
return [];
}
const cacheKey = `lastfm:chart:artists:${limit}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
const data = await this.request({
method: "chart.getTopArtists",
api_key: this.apiKey,
format: "json",
limit,
});
const artists = data.artists?.artist || [];
// Get detailed info for each artist with images
const detailedArtists = await Promise.all(
artists.map(async (artist: any) => {
// Try to get image from Fanart.tv using MBID
let image = null;
if (artist.mbid) {
try {
image = await fanartService.getArtistImage(
artist.mbid
);
} catch (error) {
// Silently fail
}
}
// Fallback to Deezer (most reliable)
if (!image) {
try {
const deezerImage =
await deezerService.getArtistImage(artist.name);
if (deezerImage) {
image = deezerImage;
}
} catch (error) {
// Silently fail
}
}
// Last fallback to Last.fm images (but filter placeholders)
if (!image) {
const lastFmImage = this.getBestImage(artist.image);
if (
lastFmImage &&
!lastFmImage.includes(
"2a96cbd8b46e442fc41c2b86b821562f"
)
) {
image = lastFmImage;
}
}
return {
type: "music",
id: artist.mbid || artist.name,
name: artist.name,
listeners: parseInt(artist.listeners || "0"),
playCount: parseInt(artist.playcount || "0"),
url: artist.url,
image,
mbid: artist.mbid,
};
})
);
// Cache for 6 hours (charts update frequently)
try {
await redisClient.setEx(
cacheKey,
21600,
JSON.stringify(detailedArtists)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return detailedArtists;
} catch (error) {
console.error("Last.fm chart artists error:", error);
return [];
}
}
}
export const lastFmService = new LastFmService();
File diff suppressed because it is too large Load Diff
+766
View File
@@ -0,0 +1,766 @@
import * as fs from "fs";
import * as path from "path";
import { parseFile } from "music-metadata";
import { prisma } from "../utils/db";
import PQueue from "p-queue";
import { CoverArtExtractor } from "./coverArtExtractor";
import { deezerService } from "./deezer";
import { normalizeArtistName, areArtistNamesSimilar, canonicalizeVariousArtists } from "../utils/artistNormalization";
// Supported audio formats
const AUDIO_EXTENSIONS = new Set([
".mp3",
".flac",
".m4a",
".aac",
".ogg",
".opus",
".wav",
".wma",
".ape",
".wv",
]);
interface ScanProgress {
filesScanned: number;
filesTotal: number;
currentFile: string;
errors: Array<{ file: string; error: string }>;
}
interface ScanResult {
tracksAdded: number;
tracksUpdated: number;
tracksRemoved: number;
errors: Array<{ file: string; error: string }>;
duration: number;
}
export class MusicScannerService {
private scanQueue = new PQueue({ concurrency: 10 });
private progressCallback?: (progress: ScanProgress) => void;
private coverArtExtractor?: CoverArtExtractor;
constructor(
progressCallback?: (progress: ScanProgress) => void,
coverCachePath?: string
) {
this.progressCallback = progressCallback;
if (coverCachePath) {
this.coverArtExtractor = new CoverArtExtractor(coverCachePath);
}
}
/**
* Scan the music directory and update the database
*/
async scanLibrary(musicPath: string): Promise<ScanResult> {
const startTime = Date.now();
const result: ScanResult = {
tracksAdded: 0,
tracksUpdated: 0,
tracksRemoved: 0,
errors: [],
duration: 0,
};
console.log(`Starting library scan: ${musicPath}`);
// Step 1: Find all audio files
const audioFiles = await this.findAudioFiles(musicPath);
console.log(`Found ${audioFiles.length} audio files`);
// Step 2: Get existing tracks from database
const existingTracks = await prisma.track.findMany({
select: {
id: true,
filePath: true,
fileModified: true,
},
});
const tracksByPath = new Map(
existingTracks.map((t) => [t.filePath, t])
);
// Step 3: Process each audio file
let filesScanned = 0;
const progress: ScanProgress = {
filesScanned: 0,
filesTotal: audioFiles.length,
currentFile: "",
errors: [],
};
for (const audioFile of audioFiles) {
await this.scanQueue.add(async () => {
try {
const relativePath = path.relative(musicPath, audioFile);
progress.currentFile = relativePath;
this.progressCallback?.(progress);
const stats = await fs.promises.stat(audioFile);
const fileModified = stats.mtime;
const existingTrack = tracksByPath.get(relativePath);
// Check if file needs updating
if (existingTrack) {
if (
existingTrack.fileModified &&
existingTrack.fileModified >= fileModified
) {
// File hasn't changed, skip
filesScanned++;
progress.filesScanned = filesScanned;
return;
}
// File changed, will update
result.tracksUpdated++;
} else {
// New file
result.tracksAdded++;
}
// Extract metadata and update database
await this.processAudioFile(
audioFile,
relativePath,
musicPath
);
} catch (err: any) {
const error = {
file: audioFile,
error: err.message || String(err),
};
result.errors.push(error);
progress.errors.push(error);
console.error(`Error processing ${audioFile}:`, err);
} finally {
filesScanned++;
progress.filesScanned = filesScanned;
this.progressCallback?.(progress);
}
});
}
await this.scanQueue.onIdle();
// Step 4: Remove tracks for files that no longer exist
const scannedPaths = new Set(
audioFiles.map((f) => path.relative(musicPath, f))
);
const tracksToRemove = existingTracks.filter(
(t) => !scannedPaths.has(t.filePath)
);
if (tracksToRemove.length > 0) {
await prisma.track.deleteMany({
where: {
id: { in: tracksToRemove.map((t) => t.id) },
},
});
result.tracksRemoved = tracksToRemove.length;
console.log(`Removed ${tracksToRemove.length} missing tracks`);
}
// Step 5: Clean up orphaned albums (albums with no tracks)
const orphanedAlbums = await prisma.album.findMany({
where: {
tracks: { none: {} },
},
select: { id: true, title: true },
});
if (orphanedAlbums.length > 0) {
console.log(`Removing ${orphanedAlbums.length} orphaned albums...`);
await prisma.album.deleteMany({
where: {
id: { in: orphanedAlbums.map((a) => a.id) },
},
});
}
// Step 6: Clean up orphaned artists (artists with no albums)
const orphanedArtists = await prisma.artist.findMany({
where: {
albums: { none: {} },
},
select: { id: true, name: true },
});
if (orphanedArtists.length > 0) {
console.log(`Removing ${orphanedArtists.length} orphaned artists: ${orphanedArtists.map(a => a.name).join(', ')}`);
await prisma.artist.deleteMany({
where: {
id: { in: orphanedArtists.map((a) => a.id) },
},
});
}
result.duration = Date.now() - startTime;
console.log(
`Scan complete: +${result.tracksAdded} ~${result.tracksUpdated} -${result.tracksRemoved} (${result.duration}ms)`
);
return result;
}
/**
* Extract the primary artist from collaboration strings
* Examples:
* "CHVRCHES & Robert Smith" -> "CHVRCHES"
* "Artist feat. Someone" -> "Artist"
* "Artist ft. Someone" -> "Artist"
* "Artist, Someone" -> "Artist"
*
* But preserves band names:
* "Earth, Wind & Fire" -> "Earth, Wind & Fire" (kept as-is)
* "The Naked and Famous" -> "The Naked and Famous" (kept as-is)
*/
private extractPrimaryArtist(artistName: string): string {
// Trim whitespace
artistName = artistName.trim();
// HIGH PRIORITY: These patterns almost always indicate collaborations
// (not band names) so we always split on them
const definiteCollaborationPatterns = [
/ feat\.? /i, // "feat." or "feat "
/ ft\.? /i, // "ft." or "ft "
/ featuring /i,
];
for (const pattern of definiteCollaborationPatterns) {
const match = artistName.split(pattern);
if (match.length > 1) {
return match[0].trim();
}
}
// LOWER PRIORITY: These might be band names, so only split if the result
// looks like a complete artist name (not truncated)
const ambiguousPatterns = [
{ pattern: / \& /, name: "&" }, // "Earth, Wind & Fire" shouldn't split
{ pattern: / and /i, name: "and" }, // "The Naked and Famous" shouldn't split
{ pattern: / with /i, name: "with" },
{ pattern: /, /, name: "," },
];
for (const { pattern } of ambiguousPatterns) {
const parts = artistName.split(pattern);
if (parts.length > 1) {
const firstPart = parts[0].trim();
const lastWord = firstPart.split(/\s+/).pop()?.toLowerCase() || "";
// Don't split if the first part ends with common incomplete words
// These suggest it's a band name, not a collaboration
const incompleteEndings = ["the", "a", "an", "and", "of", ","];
if (incompleteEndings.includes(lastWord)) {
continue; // Skip this pattern, try the next one
}
// Don't split if the first part is very short (likely incomplete)
if (firstPart.length < 4) {
continue;
}
return firstPart;
}
}
// No collaboration found, return as-is
return artistName;
}
/**
* Check if a file path is within the discovery folder
* Discovery albums are stored in paths like "discovery/Artist/Album/track.flac"
* or "Discover/Artist/Album/track.flac" (case-insensitive)
*/
private isDiscoveryPath(relativePath: string): boolean {
const normalizedPath = relativePath.toLowerCase().replace(/\\/g, "/");
// Check if path starts with "discovery/" or "discover/"
return (
normalizedPath.startsWith("discovery/") ||
normalizedPath.startsWith("discover/")
);
}
/**
* Normalize string for matching - handles encoding differences between
* file metadata and database records
*/
private normalizeForMatching(str: string): string {
return str
.toLowerCase()
.trim()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // Remove diacritics (café → cafe)
.replace(/[''´`]/g, "'") // Normalize apostrophes
.replace(/[""„]/g, '"') // Normalize quotes
.replace(/[–—−]/g, '-') // Normalize dashes
.replace(/\s+/g, ' ') // Collapse whitespace
.replace(/[^\w\s'"-]/g, ''); // Remove other special chars
}
/**
* Check if an album is part of a discovery download by matching artist name + album title.
* Uses multi-pass matching: exact match first, then partial match as fallback.
*/
private async isDiscoveryDownload(
artistName: string,
albumTitle: string
): Promise<boolean> {
if (!artistName || !albumTitle) return false;
const normalizedArtist = this.normalizeForMatching(artistName);
const normalizedAlbum = this.normalizeForMatching(albumTitle);
// Also try with primary artist extracted (handles "Artist A feat. Artist B")
const primaryArtist = this.extractPrimaryArtist(artistName);
const normalizedPrimaryArtist = this.normalizeForMatching(primaryArtist);
console.log(`[Scanner] Checking discovery: "${artistName}" → "${normalizedArtist}"`);
if (primaryArtist !== artistName) {
console.log(`[Scanner] Primary artist: "${primaryArtist}" → "${normalizedPrimaryArtist}"`);
}
console.log(`[Scanner] Album: "${albumTitle}" → "${normalizedAlbum}"`);
try {
// Get all discovery jobs (pending, processing, or recently completed)
const discoveryJobs = await prisma.downloadJob.findMany({
where: {
discoveryBatchId: { not: null },
status: { in: ["pending", "processing", "completed"] },
},
});
console.log(`[Scanner] Found ${discoveryJobs.length} discovery jobs to check`);
// Pass 1: Exact match after normalization
for (const job of discoveryJobs) {
const metadata = job.metadata as any;
const jobArtist = this.normalizeForMatching(metadata?.artistName || "");
const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || "");
if ((jobArtist === normalizedArtist || jobArtist === normalizedPrimaryArtist) && jobAlbum === normalizedAlbum) {
console.log(`[Scanner] EXACT MATCH: job ${job.id}`);
return true;
}
}
// Pass 2: Partial match fallback (handles "Album" vs "Album (Deluxe)")
for (const job of discoveryJobs) {
const metadata = job.metadata as any;
const jobArtist = this.normalizeForMatching(metadata?.artistName || "");
const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || "");
// Try matching both full artist name and extracted primary artist
const artistMatch = jobArtist === normalizedArtist ||
jobArtist === normalizedPrimaryArtist ||
normalizedArtist.includes(jobArtist) ||
jobArtist.includes(normalizedArtist) ||
normalizedPrimaryArtist.includes(jobArtist) ||
jobArtist.includes(normalizedPrimaryArtist);
const albumMatch = jobAlbum === normalizedAlbum ||
normalizedAlbum.includes(jobAlbum) ||
jobAlbum.includes(normalizedAlbum);
if (artistMatch && albumMatch) {
console.log(`[Scanner] PARTIAL MATCH: job ${job.id}`);
console.log(`[Scanner] Job: "${jobArtist}" - "${jobAlbum}"`);
return true;
}
}
// Pass 3: Album-only match (handles featured artists on discovery albums)
// If the album title matches exactly, this track is likely a featured artist on a discovery album
for (const job of discoveryJobs) {
const metadata = job.metadata as any;
const jobAlbum = this.normalizeForMatching(metadata?.albumTitle || "");
if (jobAlbum === normalizedAlbum && normalizedAlbum.length > 3) {
console.log(`[Scanner] ALBUM-ONLY MATCH (featured artist): job ${job.id}`);
console.log(`[Scanner] Track artist "${normalizedArtist}" is likely featured on "${jobAlbum}"`);
return true;
}
}
// Pass 4: Check DiscoveryAlbum table (for already processed albums) by album title
const discoveryAlbumByTitle = await prisma.discoveryAlbum.findFirst({
where: {
albumTitle: { equals: albumTitle, mode: "insensitive" },
status: { in: ["ACTIVE", "LIKED"] },
},
});
if (discoveryAlbumByTitle) {
console.log(`[Scanner] DiscoveryAlbum match (by title): ${discoveryAlbumByTitle.id}`);
return true;
}
// Pass 5: Check if artist name matches any discovery album
// This catches cases where Lidarr downloads a different album than requested
// e.g., requested "Broods - Broods" but got "Broods - Evergreen"
const discoveryAlbumByArtist = await prisma.discoveryAlbum.findFirst({
where: {
artistName: { equals: artistName, mode: "insensitive" },
status: { in: ["ACTIVE", "LIKED", "DELETED"] }, // Include DELETED to catch cleanup scenarios
},
});
if (discoveryAlbumByArtist) {
// Double-check: only match if this artist has NO library albums yet
// This prevents marking albums from artists that exist in both library and discovery
const existingLibraryAlbum = await prisma.album.findFirst({
where: {
artist: { name: { equals: artistName, mode: "insensitive" } },
location: "LIBRARY",
},
});
if (!existingLibraryAlbum) {
console.log(`[Scanner] DiscoveryAlbum match (by artist): ${discoveryAlbumByArtist.id}`);
console.log(`[Scanner] Artist "${artistName}" is a discovery-only artist`);
return true;
}
}
console.log(`[Scanner] No discovery match found`);
return false;
} catch (error) {
console.error(`[Scanner] Error checking discovery status:`, error);
return false;
}
}
/**
* Recursively find all audio files in a directory
*/
private async findAudioFiles(dirPath: string): Promise<string[]> {
const files: string[] = [];
async function walk(dir: string) {
const entries = await fs.promises.readdir(dir, {
withFileTypes: true,
});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (AUDIO_EXTENSIONS.has(ext)) {
files.push(fullPath);
}
}
}
}
await walk(dirPath);
return files;
}
/**
* Process a single audio file and update database
*/
private async processAudioFile(
absolutePath: string,
relativePath: string,
musicPath: string
): Promise<void> {
// Extract metadata
const metadata = await parseFile(absolutePath);
const stats = await fs.promises.stat(absolutePath);
// Parse basic info
const title =
metadata.common.title ||
path.basename(relativePath, path.extname(relativePath));
const trackNo = metadata.common.track.no || 0;
const duration = Math.floor(metadata.format.duration || 0);
const mime = metadata.format.codec || "audio/mpeg";
// Artist and album info
// IMPORTANT: Prefer albumartist over artist to keep albums grouped under the primary artist
// This prevents featured artists from creating separate album entries
// e.g., "Artist A feat. Artist B" track should still be under "Artist A"'s album
let rawArtistName =
metadata.common.albumartist ||
metadata.common.artist ||
"Unknown Artist";
const albumTitle = metadata.common.album || "Unknown Album";
const year = metadata.common.year || null;
// ALWAYS extract primary artist first - this handles both:
// - Featured artists: "Artist A feat. Artist B" -> "Artist A"
// - Collaborations: "Artist A & Artist B" -> "Artist A"
// Band names like "Of Mice & Men" are preserved because extractPrimaryArtist
// only splits on " feat.", " ft.", " featuring ", " & ", etc. (with spaces)
const extractedPrimaryArtist = this.extractPrimaryArtist(rawArtistName);
let artistName = extractedPrimaryArtist;
// Canonicalize Various Artists variations (VA, V.A., <Various Artists>, etc.)
artistName = canonicalizeVariousArtists(artistName);
// Try to find artist with the canonicalized name first
// This ensures "VA", "V.A.", etc. all find the canonical "Various Artists"
const normalizedPrimaryName = normalizeArtistName(artistName);
let artist = await prisma.artist.findFirst({
where: { normalizedName: normalizedPrimaryName },
});
// If no match with primary name and we actually extracted something,
// also try the full raw name (for bands like "Of Mice & Men")
if (!artist && extractedPrimaryArtist !== rawArtistName) {
const normalizedRawName = normalizeArtistName(rawArtistName);
artist = await prisma.artist.findFirst({
where: { normalizedName: normalizedRawName },
});
// If full name matches an existing artist, use that instead
if (artist) {
artistName = rawArtistName;
}
}
// Update normalized name for use below
const normalizedArtistName = normalizeArtistName(artistName);
// If we found an artist, optionally update to better capitalization
if (artist && artist.name !== artistName) {
// Check if the new name has better capitalization (starts with uppercase)
const currentNameIsLowercase = artist.name[0] === artist.name[0].toLowerCase();
const newNameIsCapitalized = artistName[0] === artistName[0].toUpperCase();
if (currentNameIsLowercase && newNameIsCapitalized) {
console.log(`Updating artist name capitalization: "${artist.name}" -> "${artistName}"`);
artist = await prisma.artist.update({
where: { id: artist.id },
data: { name: artistName },
});
}
}
if (!artist) {
// Try fuzzy matching to catch typos like "the weeknd" vs "the weekend"
// Only check artists with similar normalized names (performance optimization)
const similarArtists = await prisma.artist.findMany({
where: {
normalizedName: {
// Get artists whose normalized names start with similar prefix
startsWith: normalizedArtistName.substring(0, Math.min(3, normalizedArtistName.length)),
},
},
select: { id: true, name: true, normalizedName: true, mbid: true },
});
// Check for fuzzy matches
for (const candidate of similarArtists) {
if (areArtistNamesSimilar(artistName, candidate.name, 95)) {
console.log(`Fuzzy match found: "${artistName}" -> "${candidate.name}"`);
artist = candidate;
break;
}
}
}
if (!artist) {
// Try to find by MusicBrainz ID if available
const artistMbid = metadata.common.musicbrainz_artistid?.[0];
if (artistMbid) {
artist = await prisma.artist.findUnique({
where: { mbid: artistMbid },
});
// If we have a real MBID but no artist exists, check if there's a temp artist we should consolidate
if (!artist) {
const tempArtist = await prisma.artist.findFirst({
where: {
normalizedName: normalizedArtistName,
mbid: { startsWith: 'temp-' },
},
});
if (tempArtist) {
// Consolidate: update temp artist to real MBID
console.log(`[SCANNER] Consolidating temp artist "${tempArtist.name}" with real MBID: ${artistMbid}`);
artist = await prisma.artist.update({
where: { id: tempArtist.id },
data: { mbid: artistMbid },
});
}
}
}
if (!artist) {
// Create new artist (use a temporary MBID for now)
artist = await prisma.artist.create({
data: {
name: artistName,
normalizedName: normalizedArtistName,
mbid:
artistMbid || `temp-${Date.now()}-${Math.random()}`,
enrichmentStatus: "pending",
},
});
}
}
// Get or create album
let album = await prisma.album.findFirst({
where: {
artistId: artist.id,
title: albumTitle,
},
});
if (!album) {
// Try to find by release group MBID if available
const albumMbid = metadata.common.musicbrainz_releasegroupid;
if (albumMbid) {
album = await prisma.album.findUnique({
where: { rgMbid: albumMbid },
});
}
if (!album) {
// Create new album (use a temporary MBID for now)
const rgMbid =
albumMbid || `temp-${Date.now()}-${Math.random()}`;
// Determine if this is a discovery album:
// 1. Check file path (legacy: /music/discovery/ folder)
// 2. Check if artist+album matches a discovery download job
// 3. Check if artist is a discovery-only artist (has DISCOVER albums but no LIBRARY albums)
const isDiscoveryByPath = this.isDiscoveryPath(relativePath);
const isDiscoveryByJob = await this.isDiscoveryDownload(artistName, albumTitle);
// Check if this artist is discovery-only (has no LIBRARY albums)
// If so, any new albums from them should also be DISCOVER
let isDiscoveryArtist = false;
if (!isDiscoveryByPath && !isDiscoveryByJob) {
const artistAlbums = await prisma.album.findMany({
where: { artistId: artist.id },
select: { location: true },
});
// Artist is discovery-only if they have albums but NONE are LIBRARY
if (artistAlbums.length > 0) {
const hasLibraryAlbums = artistAlbums.some(a => a.location === "LIBRARY");
isDiscoveryArtist = !hasLibraryAlbums;
if (isDiscoveryArtist) {
console.log(`[Scanner] Discovery-only artist detected: ${artistName}`);
}
}
}
const isDiscoveryAlbum = isDiscoveryByPath || isDiscoveryByJob || isDiscoveryArtist;
album = await prisma.album.create({
data: {
title: albumTitle,
artistId: artist.id,
rgMbid,
year,
primaryType: "Album",
location: isDiscoveryAlbum ? "DISCOVER" : "LIBRARY",
},
});
// Only create OwnedAlbum record for library albums (not discovery)
// Discovery albums are temporary and should not appear in the user's library
if (!isDiscoveryAlbum) {
await prisma.ownedAlbum.create({
data: {
rgMbid,
artistId: artist.id,
source: "native_scan",
},
});
}
}
// Extract cover art if we have an extractor
// Re-extract if: no cover, OR native cover file is missing
if (this.coverArtExtractor) {
let needsExtraction = !album.coverUrl;
// Check if existing native cover file is missing
if (album.coverUrl?.startsWith("native:")) {
const nativePath = album.coverUrl.replace("native:", "");
const coverCachePath = path.join(
path.dirname(absolutePath),
"..",
"..",
"cache",
"covers",
nativePath
);
// Use the extractor's cache path instead
const extractorCachePath = path.join(
(this.coverArtExtractor as any).coverCachePath,
nativePath
);
if (!fs.existsSync(extractorCachePath)) {
needsExtraction = true;
}
}
if (needsExtraction) {
const coverPath = await this.coverArtExtractor.extractCoverArt(
absolutePath,
album.id
);
if (coverPath) {
await prisma.album.update({
where: { id: album.id },
data: { coverUrl: `native:${coverPath}` },
});
} else {
// No embedded art, try fetching from Deezer
try {
const deezerCover = await deezerService.getAlbumCover(
artistName,
albumTitle
);
if (deezerCover) {
await prisma.album.update({
where: { id: album.id },
data: { coverUrl: deezerCover },
});
}
} catch (error) {
// Silently fail - cover art is optional
}
}
}
}
}
// Upsert track
await prisma.track.upsert({
where: { filePath: relativePath },
create: {
albumId: album.id,
title,
trackNo,
duration,
mime,
filePath: relativePath,
fileModified: stats.mtime,
fileSize: stats.size,
},
update: {
albumId: album.id,
title,
trackNo,
duration,
mime,
fileModified: stats.mtime,
fileSize: stats.size,
},
});
}
}
+656
View File
@@ -0,0 +1,656 @@
import axios, { AxiosInstance } from "axios";
import { redisClient } from "../utils/redis";
import { rateLimiter } from "./rateLimiter";
class MusicBrainzService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: "https://musicbrainz.org/ws/2",
timeout: 10000,
headers: {
"User-Agent":
"Lidify/1.0.0 (https://github.com/Chevron7Locked/lidify)",
},
});
}
private async cachedRequest(
cacheKey: string,
requestFn: () => Promise<any>,
ttlSeconds = 2592000 // 30 days
) {
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
// Use global rate limiter instead of local rate limiting
const data = await rateLimiter.execute("musicbrainz", requestFn);
try {
// Use shorter TTL for null results (1 hour) vs successful results (30 days)
// This allows retrying failed lookups sooner while still caching successes
const actualTtl = data === null ? 3600 : ttlSeconds;
await redisClient.setEx(cacheKey, actualTtl, JSON.stringify(data));
} catch (err) {
console.warn("Redis set error:", err);
}
return data;
}
async searchArtist(query: string, limit = 10) {
const cacheKey = `mb:search:artist:${query}:${limit}`;
return this.cachedRequest(cacheKey, async () => {
const response = await this.client.get("/artist", {
params: {
query,
limit,
fmt: "json",
},
});
return response.data.artists || [];
});
}
async getArtist(mbid: string, includes: string[] = ["url-rels", "tags"]) {
const cacheKey = `mb:artist:${mbid}:${includes.join(",")}`;
return this.cachedRequest(cacheKey, async () => {
const response = await this.client.get(`/artist/${mbid}`, {
params: {
inc: includes.join("+"),
fmt: "json",
},
});
return response.data;
});
}
async getReleaseGroups(
artistMbid: string,
types: string[] = ["album", "ep"],
limit = 100
) {
const cacheKey = `mb:rg:${artistMbid}:${types.join(",")}:${limit}`;
return this.cachedRequest(cacheKey, async () => {
const response = await this.client.get("/release-group", {
params: {
artist: artistMbid,
type: types.join("|"),
limit,
fmt: "json",
},
});
return response.data["release-groups"] || [];
});
}
async getReleaseGroup(rgMbid: string) {
const cacheKey = `mb:rg:${rgMbid}`;
return this.cachedRequest(cacheKey, async () => {
const response = await this.client.get(`/release-group/${rgMbid}`, {
params: {
inc: "artist-credits+releases",
fmt: "json",
},
});
return response.data;
});
}
async getReleaseGroupDetails(rgMbid: string) {
const cacheKey = `mb:rg:details:${rgMbid}`;
return this.cachedRequest(cacheKey, async () => {
const response = await this.client.get(`/release-group/${rgMbid}`, {
params: {
inc: "artist-credits+releases+labels",
fmt: "json",
},
});
return response.data;
});
}
async getRelease(releaseMbid: string) {
const cacheKey = `mb:release:${releaseMbid}`;
return this.cachedRequest(cacheKey, async () => {
const response = await this.client.get(`/release/${releaseMbid}`, {
params: {
inc: "recordings+artist-credits+labels",
fmt: "json",
},
});
return response.data;
});
}
extractPrimaryArtist(artistCredits: any[]): string {
if (!artistCredits || artistCredits.length === 0)
return "Unknown Artist";
return (
artistCredits[0].name ||
artistCredits[0].artist?.name ||
"Unknown Artist"
);
}
/**
* Escape special characters for Lucene query syntax
* MusicBrainz uses Lucene, which requires escaping: + - && || ! ( ) { } [ ] ^ " ~ * ? : \ /
*/
private escapeLucene(str: string): string {
return str.replace(/([+\-&|!(){}[\]^"~*?:\\/])/g, "\\$1");
}
/**
* Normalize album/artist names for better matching
* Removes common suffixes and cleans up the string
*/
private normalizeForSearch(str: string): string {
return (
str
.replace(/\s*\([^)]*\)\s*/g, " ") // Remove parenthetical content
.replace(/\s*\[[^\]]*\]\s*/g, " ") // Remove bracketed content
// Remove "- YEAR Remaster", "- Remastered YEAR", "- Deluxe Edition", etc.
.replace(
/\s*-\s*(\d{4}\s+)?(deluxe|remastered|remaster|edition|version|expanded|bonus|explicit|clean|single|radio edit|remix|acoustic|live|mono|stereo)(\s+\d{4})?\s*(edition|version|mix)?\s*/gi,
" "
)
// Also catch standalone year suffixes like "- 2011"
.replace(/\s*-\s*\d{4}\s*$/gi, " ")
.replace(/\s+/g, " ")
.trim()
);
}
/**
* Strip all punctuation from string for fuzzy matching
* Used as a fallback when normal search fails (e.g., "Do You Realize??")
*/
private stripPunctuation(str: string): string {
return str
.replace(/[^\w\s]/g, "") // Remove all non-word, non-space chars
.replace(/\s+/g, " ")
.trim();
}
/**
* Search for an album (release-group) by title and artist name
* Returns the first matching release group or null
* Uses multiple search strategies for better matching
*/
async searchAlbum(
albumTitle: string,
artistName: string
): Promise<{ id: string; title: string } | null> {
const cacheKey = `mb:search:album:${artistName}:${albumTitle}`;
return this.cachedRequest(cacheKey, async () => {
// Strategy 1: Exact match with escaped special characters
const escapedTitle = this.escapeLucene(albumTitle);
const escapedArtist = this.escapeLucene(artistName);
try {
const query1 = `releasegroup:"${escapedTitle}" AND artist:"${escapedArtist}"`;
const response1 = await this.client.get("/release-group", {
params: {
query: query1,
limit: 5,
fmt: "json",
},
});
const releaseGroups1 = response1.data["release-groups"] || [];
if (releaseGroups1.length > 0) {
return {
id: releaseGroups1[0].id,
title: releaseGroups1[0].title,
};
}
} catch (e) {
// Continue to strategy 2
}
// Strategy 2: Normalized/cleaned title search
const normalizedTitle = this.normalizeForSearch(albumTitle);
const normalizedArtist = this.normalizeForSearch(artistName);
if (
normalizedTitle !== albumTitle ||
normalizedArtist !== artistName
) {
try {
const escapedNormTitle = this.escapeLucene(normalizedTitle);
const escapedNormArtist =
this.escapeLucene(normalizedArtist);
const query2 = `releasegroup:"${escapedNormTitle}" AND artist:"${escapedNormArtist}"`;
const response2 = await this.client.get("/release-group", {
params: {
query: query2,
limit: 5,
fmt: "json",
},
});
const releaseGroups2 =
response2.data["release-groups"] || [];
if (releaseGroups2.length > 0) {
return {
id: releaseGroups2[0].id,
title: releaseGroups2[0].title,
};
}
} catch (e) {
// Continue to strategy 3
}
}
// Strategy 3: Fuzzy search without quotes (last resort)
try {
// Use simple terms without quotes for fuzzy matching
const simpleTitle = normalizedTitle
.split(" ")
.slice(0, 3)
.join(" "); // First 3 words
const simpleArtist = normalizedArtist.split(" ")[0]; // First word of artist
const query3 = `${this.escapeLucene(
simpleTitle
)} AND artist:${this.escapeLucene(simpleArtist)}`;
const response3 = await this.client.get("/release-group", {
params: {
query: query3,
limit: 10,
fmt: "json",
},
});
const releaseGroups3 = response3.data["release-groups"] || [];
// Find a match where the artist name contains our search term
for (const rg of releaseGroups3) {
const rgArtist =
rg["artist-credit"]?.[0]?.name ||
rg["artist-credit"]?.[0]?.artist?.name ||
"";
if (
rgArtist
.toLowerCase()
.includes(simpleArtist.toLowerCase())
) {
return {
id: rg.id,
title: rg.title,
};
}
}
} catch (e) {
// All strategies failed
}
return null;
});
}
/**
* Search for a recording (track) and return album information
* This is useful when we have artist + track title but not album name
* Returns the album (release group) that the track appears on
*/
async searchRecording(
trackTitle: string,
artistName: string
): Promise<{
albumName: string;
albumMbid: string;
artistMbid: string;
trackMbid: string;
} | null> {
const cacheKey = `mb:search:recording:${artistName}:${trackTitle}`;
return this.cachedRequest(cacheKey, async () => {
try {
// Normalize track title first - removes "- 2011 Remaster", "(Radio Edit)", etc.
const normalizedTitle = this.normalizeForSearch(trackTitle);
const normalizedArtist = this.normalizeForSearch(artistName);
// Search for recording by normalized track title and artist
const escapedTitle = this.escapeLucene(normalizedTitle);
const escapedArtist = this.escapeLucene(normalizedArtist);
const query = `recording:"${escapedTitle}" AND artist:"${escapedArtist}"`;
const response = await this.client.get("/recording", {
params: {
query,
limit: 50, // Need high limit because bootleg recordings often rank first
fmt: "json",
inc: "releases+release-groups+artists",
},
});
const allRecordings = response.data.recordings || [];
console.log(
`[MusicBrainz] Query: "${trackTitle}" by "${artistName}"`
);
console.log(
`[MusicBrainz] Found ${allRecordings.length} total recordings`
);
// Log first 5 recordings for debugging
allRecordings.slice(0, 5).forEach((rec: any, i: number) => {
const disambig = rec.disambiguation || "(studio)";
const releases = rec.releases || [];
const albumNames = releases
.slice(0, 2)
.map((r: any) => r["release-group"]?.title || "?")
.join(", ");
console.log(
` ${i + 1}. [${disambig}] → ${
albumNames || "(no albums)"
}`
);
});
// Filter out live recordings - they have disambiguation like "live, 1995-07-28"
// We want the studio recording, not live versions
const recordings = allRecordings.filter((rec: any) => {
const disambig = (rec.disambiguation || "").toLowerCase();
// Skip if disambiguation contains "live" or date patterns
if (disambig.includes("live")) return false;
if (disambig.match(/\d{4}[-]\d{2}[-]\d{2}/)) return false;
if (disambig.includes("demo")) return false;
if (disambig.includes("acoustic")) return false;
if (disambig.includes("remix")) return false;
return true;
});
console.log(
`[MusicBrainz] After filtering live/demo: ${recordings.length} studio recordings`
);
if (recordings.length === 0) {
// Try fuzzy search without quotes
const normalizedTitle = this.normalizeForSearch(trackTitle);
const normalizedArtist =
this.normalizeForSearch(artistName);
const fuzzyQuery = `${this.escapeLucene(
normalizedTitle
)} AND artist:${this.escapeLucene(normalizedArtist)}`;
const fuzzyResponse = await this.client.get("/recording", {
params: {
query: fuzzyQuery,
limit: 10,
fmt: "json",
inc: "releases+release-groups+artists",
},
});
const fuzzyRecordings = fuzzyResponse.data.recordings || [];
// Find best match by checking artist name similarity
for (const rec of fuzzyRecordings) {
const recArtist =
rec["artist-credit"]?.[0]?.name ||
rec["artist-credit"]?.[0]?.artist?.name ||
"";
if (
recArtist
.toLowerCase()
.includes(
normalizedArtist.toLowerCase().split(" ")[0]
)
) {
const result = this.extractAlbumFromRecording(rec);
if (result) return result; // Only return if we found a good album
}
}
// Strategy 3: Strip all punctuation (handles "Do You Realize??" etc.)
const strippedTitle = this.stripPunctuation(trackTitle);
const strippedArtist = this.stripPunctuation(artistName);
if (strippedTitle !== normalizedTitle) {
console.log(`[MusicBrainz] Trying punctuation-stripped search: "${strippedTitle}" by ${strippedArtist}`);
const strippedQuery = `${strippedTitle} AND artist:${strippedArtist}`;
const strippedResponse = await this.client.get("/recording", {
params: {
query: strippedQuery,
limit: 10,
fmt: "json",
inc: "releases+release-groups+artists",
},
});
const strippedRecordings = strippedResponse.data.recordings || [];
console.log(`[MusicBrainz] Punctuation-stripped search found ${strippedRecordings.length} recordings`);
for (const rec of strippedRecordings) {
const recArtist =
rec["artist-credit"]?.[0]?.name ||
rec["artist-credit"]?.[0]?.artist?.name ||
"";
if (
recArtist
.toLowerCase()
.includes(strippedArtist.toLowerCase().split(" ")[0])
) {
const result = this.extractAlbumFromRecording(rec);
if (result) {
console.log(`[MusicBrainz] ✓ Found via punctuation-stripped search: ${result.albumName}`);
return result;
}
}
}
}
return null;
}
// Try each recording until we find one with a good (non-bootleg) album
for (const rec of recordings) {
const disambig = rec.disambiguation || "(no disambiguation)";
console.log(`[MusicBrainz] Trying recording: "${rec.title}" [${disambig}]`);
const result = this.extractAlbumFromRecording(rec, false);
if (result) {
console.log(`[MusicBrainz] ✓ Found album: "${result.albumName}" (MBID: ${result.albumMbid})`);
return result; // Found a good album
} else {
console.log(`[MusicBrainz] ✗ No valid album found for this recording`);
}
}
// Fallback: Try again accepting Singles/EPs as last resort
console.log(`[MusicBrainz] No official albums found, trying to find Singles/EPs...`);
for (const rec of recordings) {
const result = this.extractAlbumFromRecording(rec, true);
if (result) {
console.log(`[MusicBrainz] ✓ Found Single/EP: "${result.albumName}" (MBID: ${result.albumMbid})`);
return result;
}
}
// No good albums found in any recording
console.log(
`[MusicBrainz] No official albums or singles found for "${trackTitle}" by ${artistName} (checked ${recordings.length} recordings)`
);
return null;
} catch (error: any) {
console.error(
"MusicBrainz recording search error:",
error.message
);
return null;
}
});
}
/**
* Extract album information from a MusicBrainz recording result
* Prioritizes studio albums and filters out compilations, live albums, and bootlegs
* @param allowSingles - If true, accepts Singles/EPs as a fallback (lower threshold)
*/
private extractAlbumFromRecording(recording: any, allowSingles: boolean = false): {
albumName: string;
albumMbid: string;
artistMbid: string;
trackMbid: string;
} | null {
// Get artist MBID
const artistMbid = recording["artist-credit"]?.[0]?.artist?.id || "";
const trackMbid = recording.id || "";
// Find the best release (prefer studio albums, avoid compilations/live/bootlegs)
const releases = recording.releases || [];
if (releases.length === 0) {
return null;
}
// Score each release to find the best one
const scoredReleases = releases.map((release: any) => {
const rg = release["release-group"];
if (!rg?.id) return { release, score: -1000 };
let score = 0;
const primaryType = rg["primary-type"] || "";
const secondaryTypes: string[] = rg["secondary-types"] || [];
const title = (rg.title || "").toLowerCase();
// Primary type scoring
if (primaryType === "Album") score += 100;
else if (primaryType === "EP") score += 50;
else if (primaryType === "Single") score += 25;
else score -= 50; // Unknown type
// Heavy penalties for compilations, live, bootlegs, soundtracks
if (secondaryTypes.includes("Compilation")) score -= 200;
if (secondaryTypes.includes("Live")) score -= 150;
if (secondaryTypes.includes("Remix")) score -= 100;
if (secondaryTypes.includes("DJ-mix")) score -= 200;
if (secondaryTypes.includes("Mixtape/Street")) score -= 100;
if (secondaryTypes.includes("Soundtrack")) score -= 150; // Movie/TV soundtracks
// Title-based penalties (catch bootlegs and compilations missed by types)
if (title.match(/\d{4}[-]\d{2}[-]\d{2}/)) score -= 300; // Dates like "2006-03-11" = bootleg
if (title.includes("live at") || title.includes("live from"))
score -= 150;
if (title.includes("best of") || title.includes("greatest hits"))
score -= 200;
if (title.includes("compilation") || title.includes("collection"))
score -= 200;
if (title.includes("soundtrack")) score -= 100;
if (title.includes("various artists")) score -= 300;
if (title.includes("sounds of the")) score -= 200; // "Sounds of the 70s" etc.
if (title.includes("deep sounds")) score -= 200;
// Bonus for official status
if (release.status === "Official") score += 20;
return { release, score };
});
// Sort by score (highest first)
scoredReleases.sort((a: any, b: any) => b.score - a.score);
// Find the first release with a GOOD score
// Normal mode: score > 50 (studio album = 100+, EP = 50+)
// Allow singles mode: score > 0 (Single = 25+, excludes compilations with negative scores)
const threshold = allowSingles ? 0 : 50;
const bestResult = scoredReleases.find((r: any) => r.score > threshold);
if (!bestResult) {
// No good releases found with this threshold - return null so we try the next recording
const modeText = allowSingles ? "singles" : "albums";
const topScores = scoredReleases.slice(0, 3).map((r: any) => {
const title =
r.release["release-group"]?.title || r.release.title;
return `"${title}" (${r.score})`;
});
console.log(
`[MusicBrainz] Skipping recording - no ${modeText} found in ${
releases.length
} releases (threshold: ${threshold}). Top scores: ${topScores.join(", ")}`
);
return null;
}
const bestRelease = bestResult.release;
const releaseGroup = bestRelease["release-group"];
if (!releaseGroup?.id) {
return null;
}
console.log(
`[MusicBrainz] Selected "${releaseGroup.title}" (score: ${bestResult.score}) from ${releases.length} releases`
);
return {
albumName:
releaseGroup.title || bestRelease.title || "Unknown Album",
albumMbid: releaseGroup.id,
artistMbid,
trackMbid,
};
}
/**
* Clear cached recording search result
* Useful for retrying failed lookups
*/
async clearRecordingCache(trackTitle: string, artistName: string): Promise<boolean> {
const cacheKey = `mb:search:recording:${artistName}:${trackTitle}`;
try {
await redisClient.del(cacheKey);
console.log(`[MusicBrainz] Cleared cache for: "${trackTitle}" by ${artistName}`);
return true;
} catch (err) {
console.warn("Redis del error:", err);
return false;
}
}
/**
* Clear all stale null cache entries for recording searches
* Returns the number of entries cleared
*/
async clearStaleRecordingCaches(): Promise<number> {
try {
// Get all recording cache keys
const keys = await redisClient.keys("mb:search:recording:*");
let cleared = 0;
for (const key of keys) {
const value = await redisClient.get(key);
if (value === "null") {
await redisClient.del(key);
cleared++;
}
}
console.log(`[MusicBrainz] Cleared ${cleared} stale null cache entries`);
return cleared;
} catch (err) {
console.error("Error clearing stale caches:", err);
return 0;
}
}
}
export const musicBrainzService = new MusicBrainzService();
+225
View File
@@ -0,0 +1,225 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export type NotificationType =
| "system"
| "download_complete"
| "download_failed"
| "playlist_ready"
| "import_complete"
| "error";
export interface CreateNotificationParams {
userId: string;
type: NotificationType;
title: string;
message?: string;
metadata?: Record<string, any>;
}
class NotificationService {
/**
* Create a new notification for a user
*/
async create(params: CreateNotificationParams) {
const { userId, type, title, message, metadata } = params;
const notification = await prisma.notification.create({
data: {
userId,
type,
title,
message,
metadata,
},
});
console.log(
`[NOTIFICATION] Created: ${type} - ${title} for user ${userId}`
);
return notification;
}
/**
* Get all uncleared notifications for a user
*/
async getForUser(userId: string, includeRead = true) {
return prisma.notification.findMany({
where: {
userId,
cleared: false,
...(includeRead ? {} : { read: false }),
},
orderBy: { createdAt: "desc" },
take: 100,
});
}
/**
* Get unread count for a user
*/
async getUnreadCount(userId: string) {
return prisma.notification.count({
where: {
userId,
cleared: false,
read: false,
},
});
}
/**
* Mark a notification as read
*/
async markAsRead(id: string, userId: string) {
return prisma.notification.updateMany({
where: { id, userId },
data: { read: true },
});
}
/**
* Mark all notifications as read for a user
*/
async markAllAsRead(userId: string) {
return prisma.notification.updateMany({
where: { userId, cleared: false },
data: { read: true },
});
}
/**
* Clear a notification (remove from view but keep in DB)
*/
async clear(id: string, userId: string) {
return prisma.notification.updateMany({
where: { id, userId },
data: { cleared: true },
});
}
/**
* Clear all notifications for a user
*/
async clearAll(userId: string) {
return prisma.notification.updateMany({
where: { userId },
data: { cleared: true },
});
}
/**
* Delete old cleared notifications (cleanup job)
*/
async deleteOldCleared(daysOld = 30) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - daysOld);
const result = await prisma.notification.deleteMany({
where: {
cleared: true,
createdAt: { lt: cutoff },
},
});
if (result.count > 0) {
console.log(
`[NOTIFICATION] Cleaned up ${result.count} old notifications`
);
}
return result;
}
// === Convenience methods for common notification types ===
/**
* Notify user that a download completed
*/
async notifyDownloadComplete(
userId: string,
subject: string,
albumId?: string,
artistId?: string
) {
return this.create({
userId,
type: "download_complete",
title: "Download Complete",
message: `${subject} has been downloaded and added to your library`,
metadata: { albumId, artistId },
});
}
/**
* Notify user that a download failed
*/
async notifyDownloadFailed(
userId: string,
subject: string,
error?: string
) {
return this.create({
userId,
type: "download_failed",
title: "Download Failed",
message: `Failed to download ${subject}${
error ? `: ${error}` : ""
}`,
metadata: { subject, error },
});
}
/**
* Notify user that a playlist is ready
*/
async notifyPlaylistReady(
userId: string,
playlistName: string,
playlistId: string,
trackCount: number
) {
return this.create({
userId,
type: "playlist_ready",
title: "Playlist Ready",
message: `"${playlistName}" is ready with ${trackCount} tracks`,
metadata: { playlistId, playlistName, trackCount },
});
}
/**
* Notify user that a Spotify import completed
*/
async notifyImportComplete(
userId: string,
playlistName: string,
playlistId: string,
matchedTracks: number,
totalTracks: number
) {
const message = `"${playlistName}" imported with ${matchedTracks} of ${totalTracks} tracks`;
return this.create({
userId,
type: "import_complete",
title: "Import Complete",
message,
metadata: { playlistId, playlistName, matchedTracks, totalTracks },
});
}
/**
* System notification (cache cleared, sync complete, etc.)
*/
async notifySystem(userId: string, title: string, message?: string) {
return this.create({
userId,
type: "system",
title,
message,
});
}
}
export const notificationService = new NotificationService();
+184
View File
@@ -0,0 +1,184 @@
import axios, { AxiosInstance } from "axios";
import { config } from "../config";
interface PlaylistTrack {
artistName: string;
albumTitle?: string;
trackTitle: string;
reason?: string;
}
interface GeneratePlaylistParams {
userId: string;
topArtists: Array<{ name: string; playCount: number; genres: string[] }>;
recentDiscoveries: string[];
likedArtists: string[];
dislikedArtists: string[];
targetCount: number;
}
class OpenAIService {
private client: AxiosInstance;
private apiKey: string;
constructor() {
this.apiKey = config.openai.apiKey;
this.client = axios.create({
baseURL: "https://api.openai.com/v1",
timeout: 60000,
headers: {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
},
});
}
async generateWeeklyPlaylist(
params: GeneratePlaylistParams
): Promise<PlaylistTrack[]> {
const {
topArtists,
recentDiscoveries,
likedArtists,
dislikedArtists,
targetCount,
} = params;
// Build context for AI
const topArtistsText = topArtists
.slice(0, 20)
.map(
(a) =>
`${a.name} (${a.playCount} plays, genres: ${a.genres.join(
", "
)})`
)
.join("\n");
const prompt = `You are a music curator creating a personalized "Discover Weekly" playlist.
USER'S LISTENING PROFILE:
Top Artists (last 90 days):
${topArtistsText}
Recent Discoveries (NEW artists to explore): ${recentDiscoveries.join(", ") || "None yet"}
Liked Artists: ${likedArtists.join(", ") || "None"}
Disliked Artists (NEVER recommend): ${dislikedArtists.join(", ") || "None"}
TASK:
Generate a ${targetCount}-track playlist with this breakdown:
- 25% (${Math.round(
targetCount * 0.25
)} tracks): From the user's top artists (1-2 tracks max per artist)
- 75% (${Math.round(
targetCount * 0.75
)} tracks): NEW discoveries from the "Recent Discoveries" list above
CRITICAL REQUIREMENTS:
1. PRIORITIZE new artists from the "Recent Discoveries" list - this is the main goal
2. Include only 1-2 well-known tracks from the user's top artists as "familiar anchors"
3. For new discoveries, choose popular, accessible tracks that will hook the listener
4. Maintain genre consistency with user's preferences
5. NEVER include artists from the "Disliked Artists" list
6. Variety of moods and tempos across the playlist
OUTPUT FORMAT (JSON):
{
"tracks": [
{
"artistName": "Artist Name",
"trackTitle": "Track Title",
"reason": "Brief reason (e.g., 'Popular track from your favorite artist' or 'Similar to Jamiroquai')"
}
]
}
Return ONLY valid JSON, no markdown formatting.`;
try {
const response = await this.client.post("/chat/completions", {
model: "gpt-4-turbo",
messages: [
{
role: "system",
content:
"You are an expert music curator who creates personalized playlists based on listening history. You always respond with valid JSON only. Ensure all strings are properly escaped.",
},
{
role: "user",
content: prompt,
},
],
max_tokens: 2000,
temperature: 0.7,
response_format: { type: "json_object" },
});
const content = response.data.choices[0].message.content.trim();
// Remove markdown code blocks if present
let jsonContent = content;
if (content.startsWith("```json")) {
jsonContent = content
.replace(/```json\n?/g, "")
.replace(/```\n?/g, "")
.trim();
} else if (content.startsWith("```")) {
jsonContent = content.replace(/```\n?/g, "").trim();
}
const result = JSON.parse(jsonContent);
return result.tracks || [];
} catch (error: any) {
console.error(
"OpenAI API error:",
error.response?.data || error.message
);
// Log the raw response content for debugging
if (error instanceof SyntaxError) {
console.error("Failed to parse JSON response");
}
throw new Error("Failed to generate playlist with AI");
}
}
async enhanceTrackRecommendation(
track: { artist: string; title: string },
userContext: string
): Promise<string> {
const prompt = `Given this track: "${track.title}" by ${track.artist}
User context: ${userContext}
Provide a single-sentence reason why this track would fit in their Discover Weekly playlist.
Be concise and engaging (max 15 words).`;
try {
const response = await this.client.post("/chat/completions", {
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content:
"You write brief, engaging music recommendations.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
max_tokens: 50,
});
return response.data.choices[0].message.content.trim();
} catch (error) {
console.error("OpenAI enhancement error:", error);
return "Recommended based on your listening history";
}
}
}
export const openAIService = new OpenAIService();
+252
View File
@@ -0,0 +1,252 @@
import { prisma } from "../utils/db";
import fs from "fs/promises";
import path from "path";
import { config } from "../config";
/**
* Service to cache podcast cover images locally
* Podcasts are already stored in database (from RSS feeds)
* This service adds cover image caching to avoid repeated downloads
*/
interface CoverSyncResult {
synced: number;
failed: number;
skipped: number;
errors: string[];
}
export class PodcastCacheService {
private coverCacheDir: string;
constructor() {
// Store covers in: <MUSIC_PATH>/cover-cache/podcasts/
this.coverCacheDir = path.join(
config.music.musicPath,
"cover-cache",
"podcasts"
);
}
/**
* Sync cover images for all podcasts
*/
async syncAllCovers(): Promise<CoverSyncResult> {
const result: CoverSyncResult = {
synced: 0,
failed: 0,
skipped: 0,
errors: [],
};
try {
console.log(" Starting podcast cover sync...");
// Ensure cover cache directory exists
await fs.mkdir(this.coverCacheDir, { recursive: true });
// Fetch all podcasts from database
const podcasts = await prisma.podcast.findMany({
where: {
localCoverPath: null, // Only sync podcasts without local covers
imageUrl: { not: null },
},
});
console.log(
`[PODCAST] Found ${podcasts.length} podcasts needing cover sync`
);
for (const podcast of podcasts) {
try {
if (podcast.imageUrl) {
const localPath = await this.downloadCover(
podcast.id,
podcast.imageUrl,
"podcast"
);
if (localPath) {
await prisma.podcast.update({
where: { id: podcast.id },
data: { localCoverPath: localPath },
});
result.synced++;
console.log(` Synced cover for: ${podcast.title}`);
} else {
result.skipped++;
}
}
} catch (error: any) {
result.failed++;
const errorMsg = `Failed to sync cover for ${podcast.title}: ${error.message}`;
result.errors.push(errorMsg);
console.error(`${errorMsg}`);
}
}
console.log("\nPodcast Cover Sync Summary:");
console.log(` Synced: ${result.synced}`);
console.log(` Failed: ${result.failed}`);
console.log(` Skipped: ${result.skipped}`);
return result;
} catch (error: any) {
console.error(" Podcast cover sync failed:", error);
throw error;
}
}
/**
* Sync cover images for all podcast episodes (if they have unique covers)
*/
async syncEpisodeCovers(): Promise<CoverSyncResult> {
const result: CoverSyncResult = {
synced: 0,
failed: 0,
skipped: 0,
errors: [],
};
try {
console.log(" Starting podcast episode cover sync...");
await fs.mkdir(this.coverCacheDir, { recursive: true });
// Fetch episodes with unique covers (different from podcast cover)
const episodes = await prisma.podcastEpisode.findMany({
where: {
localCoverPath: null,
imageUrl: { not: null },
},
include: {
podcast: {
select: {
imageUrl: true,
},
},
},
});
// Filter to only episodes with unique covers
const uniqueEpisodes = episodes.filter(
(ep) => ep.imageUrl !== ep.podcast.imageUrl
);
console.log(
`[PODCAST] Found ${uniqueEpisodes.length} episodes with unique covers`
);
for (const episode of uniqueEpisodes) {
try {
if (episode.imageUrl) {
const localPath = await this.downloadCover(
episode.id,
episode.imageUrl,
"episode"
);
if (localPath) {
await prisma.podcastEpisode.update({
where: { id: episode.id },
data: { localCoverPath: localPath },
});
result.synced++;
console.log(
` Synced cover for episode: ${episode.title}`
);
} else {
result.skipped++;
}
}
} catch (error: any) {
result.failed++;
const errorMsg = `Failed to sync cover for episode ${episode.title}: ${error.message}`;
result.errors.push(errorMsg);
console.error(`${errorMsg}`);
}
}
console.log("\nEpisode Cover Sync Summary:");
console.log(` Synced: ${result.synced}`);
console.log(` Failed: ${result.failed}`);
console.log(` Skipped: ${result.skipped}`);
return result;
} catch (error: any) {
console.error(" Episode cover sync failed:", error);
throw error;
}
}
/**
* Download a cover image and save it locally
*/
private async downloadCover(
id: string,
imageUrl: string,
type: "podcast" | "episode"
): Promise<string | null> {
try {
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`
);
}
const buffer = await response.arrayBuffer();
const fileName = `${type}_${id}.jpg`;
const filePath = path.join(this.coverCacheDir, fileName);
await fs.writeFile(filePath, Buffer.from(buffer));
return filePath;
} catch (error: any) {
console.error(
`Failed to download cover for ${type} ${id}:`,
error.message
);
return null;
}
}
/**
* Clean up orphaned covers
*/
async cleanupOrphanedCovers(): Promise<number> {
const podcasts = await prisma.podcast.findMany({
select: { localCoverPath: true },
});
const episodes = await prisma.podcastEpisode.findMany({
select: { localCoverPath: true },
});
const validCoverPaths = new Set([
...podcasts
.filter((p) => p.localCoverPath)
.map((p) => path.basename(p.localCoverPath!)),
...episodes
.filter((e) => e.localCoverPath)
.map((e) => path.basename(e.localCoverPath!)),
]);
let deleted = 0;
const files = await fs.readdir(this.coverCacheDir);
for (const file of files) {
if (!validCoverPaths.has(file)) {
await fs.unlink(path.join(this.coverCacheDir, file));
deleted++;
console.log(` [DELETE] Deleted orphaned podcast cover: ${file}`);
}
}
return deleted;
}
}
// Export singleton instance
export const podcastCacheService = new PodcastCacheService();
+441
View File
@@ -0,0 +1,441 @@
import { prisma } from "../utils/db";
import { config } from "../config";
import fs from "fs/promises";
import path from "path";
import axios from "axios";
/**
* PodcastDownloadService - Background download and caching of podcast episodes
*
* Features:
* - Non-blocking background downloads when episodes are played
* - 30-day cache expiry with automatic cleanup
* - Proper range request support for cached files
*/
// Track in-progress downloads to avoid duplicates
const downloadingEpisodes = new Set<string>();
// Track download progress (episodeId -> { bytesDownloaded, totalBytes })
interface DownloadProgress {
bytesDownloaded: number;
totalBytes: number;
}
const downloadProgress = new Map<string, DownloadProgress>();
// Cache directory for podcast audio files
const getPodcastCacheDir = (): string => {
return path.join(config.music.transcodeCachePath, "../podcast-audio");
};
/**
* Get download progress for an episode
* Returns { progress: 0-100, downloading: boolean } or null if not downloading
*/
export function getDownloadProgress(episodeId: string): { progress: number; downloading: boolean } | null {
if (!downloadingEpisodes.has(episodeId)) {
return null;
}
const progress = downloadProgress.get(episodeId);
if (!progress || progress.totalBytes === 0) {
return { progress: 0, downloading: true };
}
const percent = Math.round((progress.bytesDownloaded / progress.totalBytes) * 100);
return { progress: Math.min(100, percent), downloading: true };
}
/**
* Check if a cached file exists and is valid
* Returns null if file doesn't exist, is empty, or is still being downloaded
*/
export async function getCachedFilePath(episodeId: string): Promise<string | null> {
// Don't return cache path if still downloading - file may be incomplete
if (downloadingEpisodes.has(episodeId)) {
console.log(`[PODCAST-DL] Episode ${episodeId} is still downloading, not using cache`);
return null;
}
const cacheDir = getPodcastCacheDir();
const cachedPath = path.join(cacheDir, `${episodeId}.mp3`);
try {
await fs.access(cachedPath, fs.constants.F_OK);
const stats = await fs.stat(cachedPath);
// File must be > 0 bytes to be valid
if (stats.size > 0) {
// Strong validation: if we know the canonical remote file size, require the cache to match.
// This prevents "cached=true" when we only downloaded part of the file (which breaks seeking and causes 416s).
try {
const episode = await prisma.podcastEpisode.findUnique({
where: { id: episodeId },
select: { fileSize: true },
});
if (episode?.fileSize && episode.fileSize > 0) {
const expected = episode.fileSize;
const actual = stats.size;
const variance = Math.abs(actual - expected) / expected;
if (variance > 0.01) {
console.log(
`[PODCAST-DL] Episode size mismatch vs episode.fileSize for ${episodeId}: actual ${actual} vs expected ${expected} (variance ${Math.round(
variance * 100
)}%), deleting cache`
);
await fs.unlink(cachedPath).catch(() => {});
await prisma.podcastDownload.deleteMany({
where: { episodeId },
});
return null;
}
}
} catch {
// If this check fails, fall back to prior DB-record based validation
}
// Check database record exists
const dbRecord = await prisma.podcastDownload.findFirst({
where: { episodeId }
});
// If no DB record, file might be incomplete or stale
if (!dbRecord) {
console.log(`[PODCAST-DL] No DB record for ${episodeId}, deleting stale cache file`);
await fs.unlink(cachedPath).catch(() => {});
return null;
}
// Validate file size matches what we recorded (allow 1% variance for filesystem differences)
const expectedSize = dbRecord.fileSizeMb * 1024 * 1024;
const actualSize = stats.size;
const variance = Math.abs(actualSize - expectedSize) / expectedSize;
if (expectedSize > 0 && variance > 0.01) {
console.log(`[PODCAST-DL] Size mismatch for ${episodeId}: actual ${actualSize} vs expected ${Math.round(expectedSize)}, deleting`);
await fs.unlink(cachedPath).catch(() => {});
await prisma.podcastDownload.deleteMany({ where: { episodeId } });
return null;
}
// Update last accessed time
await prisma.podcastDownload.updateMany({
where: { episodeId },
data: { lastAccessedAt: new Date() }
});
console.log(`[PODCAST-DL] Cache valid for ${episodeId}: ${stats.size} bytes`);
return cachedPath;
}
return null;
} catch {
return null;
}
}
/**
* Start a background download for an episode
* Returns immediately, download happens asynchronously
*/
export function downloadInBackground(
episodeId: string,
audioUrl: string,
userId: string
): void {
// Skip if already downloading
if (downloadingEpisodes.has(episodeId)) {
console.log(`[PODCAST-DL] Already downloading episode ${episodeId}, skipping`);
return;
}
// Mark as downloading
downloadingEpisodes.add(episodeId);
// Start download in background (don't await)
performDownload(episodeId, audioUrl, userId)
.catch(err => {
console.error(`[PODCAST-DL] Background download failed for ${episodeId}:`, err.message);
})
.finally(() => {
downloadingEpisodes.delete(episodeId);
});
}
/**
* Perform the actual download with retry support
*/
async function performDownload(
episodeId: string,
audioUrl: string,
userId: string,
attempt: number = 1
): Promise<void> {
const maxAttempts = 3;
console.log(`[PODCAST-DL] Starting background download for episode ${episodeId} (attempt ${attempt}/${maxAttempts})`);
const cacheDir = getPodcastCacheDir();
// Ensure cache directory exists
await fs.mkdir(cacheDir, { recursive: true });
const tempPath = path.join(cacheDir, `${episodeId}.tmp`);
const finalPath = path.join(cacheDir, `${episodeId}.mp3`);
try {
// Check if already cached (and validated)
downloadingEpisodes.delete(episodeId); // Temporarily remove to check cache
const existingCached = await getCachedFilePath(episodeId);
downloadingEpisodes.add(episodeId); // Re-add
if (existingCached) {
console.log(`[PODCAST-DL] Episode ${episodeId} already cached, skipping download`);
return;
}
// Clean up any partial temp files from previous attempts
await fs.unlink(tempPath).catch(() => {});
// Download the file with longer timeout for large podcasts
const response = await axios.get(audioUrl, {
responseType: 'stream',
timeout: 600000, // 10 minute timeout for large files (3+ hour podcasts)
headers: {
'User-Agent': 'Lidify/1.0 (https://github.com/Chevron7Locked/lidify)'
},
// Don't let axios decompress - we want raw bytes
decompress: false
});
const contentLength = parseInt(response.headers["content-length"] || "0", 10);
let expectedBytes = Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0;
// If the origin provides Content-Length, treat it as ground truth and persist it.
// This prevents us from "accepting" partial caches that later break seeking.
if (expectedBytes > 0) {
try {
const episode = await prisma.podcastEpisode.findUnique({
where: { id: episodeId },
select: { fileSize: true },
});
const existing = episode?.fileSize || 0;
if (!existing) {
await prisma.podcastEpisode.update({
where: { id: episodeId },
data: { fileSize: expectedBytes },
});
} else {
const variance = Math.abs(existing - expectedBytes) / existing;
if (variance > 0.01) {
await prisma.podcastEpisode.update({
where: { id: episodeId },
data: { fileSize: expectedBytes },
});
}
}
} catch {
// Non-fatal
}
} else {
// Fallback: use DB fileSize if present (better than nothing)
try {
const episode = await prisma.podcastEpisode.findUnique({
where: { id: episodeId },
select: { fileSize: true },
});
if (episode?.fileSize && episode.fileSize > 0) {
expectedBytes = episode.fileSize;
}
} catch {}
}
console.log(
`[PODCAST-DL] Downloading ${episodeId} (${expectedBytes > 0 ? Math.round(expectedBytes / 1024 / 1024) : 0}MB)`
);
// Initialize progress tracking
downloadProgress.set(episodeId, {
bytesDownloaded: 0,
totalBytes: expectedBytes || 0,
});
// Write to temp file first with progress tracking
const writeStream = (await import('fs')).createWriteStream(tempPath);
let bytesDownloaded = 0;
let lastLogTime = Date.now();
await new Promise<void>((resolve, reject) => {
response.data.on('data', (chunk: Buffer) => {
bytesDownloaded += chunk.length;
downloadProgress.set(episodeId, { bytesDownloaded, totalBytes: contentLength });
// Log progress every 30 seconds for long downloads
const now = Date.now();
if (now - lastLogTime > 30000) {
const percent = contentLength > 0 ? Math.round((bytesDownloaded / contentLength) * 100) : 0;
console.log(`[PODCAST-DL] Download progress ${episodeId}: ${percent}% (${Math.round(bytesDownloaded / 1024 / 1024)}MB)`);
lastLogTime = now;
}
});
response.data.on('end', () => {
writeStream.end(() => resolve());
});
response.data.pipe(writeStream, { end: false });
writeStream.on('error', (err) => {
response.data.destroy();
reject(err);
});
response.data.on('error', (err: Error) => {
writeStream.destroy();
reject(err);
});
// Handle aborted connections
response.data.on('aborted', () => {
writeStream.destroy();
reject(new Error('Download aborted by server'));
});
});
// Verify file was written and is complete
const stats = await fs.stat(tempPath);
if (stats.size === 0) {
await fs.unlink(tempPath).catch(() => {});
throw new Error('Downloaded file is empty');
}
// Check completeness when we know an expected size (prefer Content-Length).
// Allow a small variance because some servers are inconsistent at the byte level.
if (expectedBytes > 0) {
const variance = Math.abs(stats.size - expectedBytes) / expectedBytes;
if (variance > 0.01) {
const percentComplete = Math.round((stats.size / expectedBytes) * 100);
console.error(`[PODCAST-DL] Incomplete download for ${episodeId}: ${stats.size}/${expectedBytes} bytes (${percentComplete}%)`);
await fs.unlink(tempPath).catch(() => {});
throw new Error(`Download incomplete: got ${stats.size} bytes, expected ${expectedBytes}`);
}
}
// Move temp file to final location
await fs.rename(tempPath, finalPath);
// Record in database
const fileSizeMb = stats.size / 1024 / 1024;
await prisma.podcastDownload.upsert({
where: {
userId_episodeId: { userId, episodeId }
},
create: {
userId,
episodeId,
localPath: finalPath,
fileSizeMb,
downloadedAt: new Date(),
lastAccessedAt: new Date()
},
update: {
localPath: finalPath,
fileSizeMb,
downloadedAt: new Date(),
lastAccessedAt: new Date()
}
});
console.log(`[PODCAST-DL] Successfully cached episode ${episodeId} (${fileSizeMb.toFixed(1)}MB)`);
// Clean up progress tracking
downloadProgress.delete(episodeId);
} catch (error: any) {
// Clean up temp file and progress tracking on error
await fs.unlink(tempPath).catch(() => {});
downloadProgress.delete(episodeId);
// Retry on failure
if (attempt < maxAttempts) {
console.log(`[PODCAST-DL] Download failed (attempt ${attempt}), retrying in 5s: ${error.message}`);
await new Promise(resolve => setTimeout(resolve, 5000));
return performDownload(episodeId, audioUrl, userId, attempt + 1);
}
throw error;
}
}
/**
* Clean up cached episodes older than 30 days
* Should be called periodically (e.g., daily)
*/
export async function cleanupExpiredCache(): Promise<{ deleted: number; freedMb: number }> {
console.log('[PODCAST-DL] Starting cache cleanup...');
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Find expired downloads
const expiredDownloads = await prisma.podcastDownload.findMany({
where: {
lastAccessedAt: { lt: thirtyDaysAgo }
}
});
let deleted = 0;
let freedMb = 0;
for (const download of expiredDownloads) {
try {
// Delete file from disk
await fs.unlink(download.localPath).catch(() => {});
// Delete database record
await prisma.podcastDownload.delete({
where: { id: download.id }
});
deleted++;
freedMb += download.fileSizeMb;
console.log(`[PODCAST-DL] Deleted expired cache: ${path.basename(download.localPath)}`);
} catch (err: any) {
console.error(`[PODCAST-DL] Failed to delete ${download.localPath}:`, err.message);
}
}
console.log(`[PODCAST-DL] Cleanup complete: ${deleted} files deleted, ${freedMb.toFixed(1)}MB freed`);
return { deleted, freedMb };
}
/**
* Get cache statistics
*/
export async function getCacheStats(): Promise<{
totalFiles: number;
totalSizeMb: number;
oldestFile: Date | null;
}> {
const downloads = await prisma.podcastDownload.findMany({
select: {
fileSizeMb: true,
downloadedAt: true
},
orderBy: { downloadedAt: 'asc' }
});
return {
totalFiles: downloads.length,
totalSizeMb: downloads.reduce((sum, d) => sum + d.fileSizeMb, 0),
oldestFile: downloads.length > 0 ? downloads[0].downloadedAt : null
};
}
/**
* Check if an episode is currently being downloaded
*/
export function isDownloading(episodeId: string): boolean {
return downloadingEpisodes.has(episodeId);
}
+94
View File
@@ -0,0 +1,94 @@
import { getSystemSettings } from "../utils/systemSettings";
import { decrypt } from "../utils/encryption";
let podcastindexApi: any = null;
/**
* Initialize PodcastIndex API client with credentials from system settings
*/
async function initPodcastindexClient() {
const settings = await getSystemSettings();
if (!settings?.podcastindexEnabled) {
throw new Error("PodcastIndex is not enabled in system settings");
}
if (!settings.podcastindexApiKey || !settings.podcastindexApiSecret) {
throw new Error("PodcastIndex API credentials not configured");
}
const apiKey = decrypt(settings.podcastindexApiKey);
const apiSecret = decrypt(settings.podcastindexApiSecret);
const podcastIndexApi = require("podcast-index-api");
podcastindexApi = podcastIndexApi(apiKey, apiSecret, "Lidify");
return podcastindexApi;
}
/**
* Search podcasts by term
*/
export async function searchPodcasts(query: string, max: number = 20) {
const client = await initPodcastindexClient();
const results = await client.searchByTerm(query, max);
return results;
}
/**
* Get trending podcasts
*/
export async function getTrendingPodcasts(max: number = 10, category?: string) {
const client = await initPodcastindexClient();
const results = await client.podcastsTrending(max, null, null, category);
return results;
}
/**
* Get podcasts by category
*/
export async function getPodcastsByCategory(
category: string,
max: number = 20
) {
const client = await initPodcastindexClient();
const results = await client.searchByTerm("", max, null, null);
// Filter by category
return results;
}
/**
* Get all categories
*/
export async function getCategories() {
const client = await initPodcastindexClient();
const results = await client.categoriesList();
return results;
}
/**
* Get podcast by feed URL
*/
export async function getPodcastByFeedUrl(feedUrl: string) {
const client = await initPodcastindexClient();
const results = await client.podcastsByFeedUrl(feedUrl);
return results;
}
/**
* Get podcast by iTunes ID
*/
export async function getPodcastByItunesId(itunesId: string) {
const client = await initPodcastindexClient();
const results = await client.podcastsByFeedItunesId(itunesId);
return results;
}
/**
* Get recent podcasts
*/
export async function getRecentPodcasts(max: number = 20) {
const client = await initPodcastindexClient();
const results = await client.recentFeeds(max);
return results;
}
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
/**
* Global Rate Limiter Service
*
* Provides centralized rate limiting with exponential backoff for all external API calls.
* Implements circuit breaker pattern to pause requests when rate limited.
*/
import PQueue from "p-queue";
interface RateLimitConfig {
/** Requests per interval */
intervalCap: number;
/** Interval in milliseconds */
interval: number;
/** Maximum concurrent requests */
concurrency: number;
/** Maximum retries on 429 */
maxRetries: number;
/** Base delay for exponential backoff (ms) */
baseDelay: number;
}
interface ServiceConfig {
lastfm: RateLimitConfig;
musicbrainz: RateLimitConfig;
deezer: RateLimitConfig;
lidarr: RateLimitConfig;
coverart: RateLimitConfig;
}
// Service-specific rate limit configurations
const SERVICE_CONFIGS: ServiceConfig = {
lastfm: {
intervalCap: 3, // 3 requests per second (Last.fm allows 5, but we're conservative)
interval: 1000,
concurrency: 2,
maxRetries: 3,
baseDelay: 1000,
},
musicbrainz: {
intervalCap: 1, // 1 request per second (MusicBrainz is strict)
interval: 1100, // Slightly over 1 second to be safe
concurrency: 1,
maxRetries: 3,
baseDelay: 2000,
},
deezer: {
intervalCap: 25, // Deezer is more lenient
interval: 5000,
concurrency: 5,
maxRetries: 2,
baseDelay: 500,
},
lidarr: {
intervalCap: 10, // Local service, can be faster
interval: 1000,
concurrency: 3,
maxRetries: 2,
baseDelay: 500,
},
coverart: {
intervalCap: 5, // Cover Art Archive - conservative rate
interval: 1000,
concurrency: 3,
maxRetries: 2,
baseDelay: 1000,
},
};
type ServiceName = keyof ServiceConfig;
interface CircuitState {
isOpen: boolean;
openedAt: number;
consecutiveFailures: number;
resetAfterMs: number;
}
class GlobalRateLimiter {
private queues: Map<ServiceName, PQueue> = new Map();
private circuitBreakers: Map<ServiceName, CircuitState> = new Map();
private globalPaused = false;
private globalPauseUntil = 0;
constructor() {
// Initialize queues for each service
for (const [service, config] of Object.entries(SERVICE_CONFIGS)) {
this.queues.set(
service as ServiceName,
new PQueue({
concurrency: config.concurrency,
intervalCap: config.intervalCap,
interval: config.interval,
carryoverConcurrencyCount: true,
})
);
this.circuitBreakers.set(service as ServiceName, {
isOpen: false,
openedAt: 0,
consecutiveFailures: 0,
resetAfterMs: 30000, // 30 seconds default
});
}
console.log("Global rate limiter initialized");
}
/**
* Execute a request with rate limiting and automatic retry
*/
async execute<T>(
service: ServiceName,
requestFn: () => Promise<T>,
options?: {
priority?: number;
skipRetry?: boolean;
}
): Promise<T> {
const queue = this.queues.get(service);
const config = SERVICE_CONFIGS[service];
if (!queue || !config) {
throw new Error(`Unknown service: ${service}`);
}
// Check global pause
if (this.globalPaused && Date.now() < this.globalPauseUntil) {
const waitTime = this.globalPauseUntil - Date.now();
console.log(`Global rate limit pause - waiting ${waitTime}ms`);
await this.sleep(waitTime);
}
// Check circuit breaker
const circuit = this.circuitBreakers.get(service)!;
if (circuit.isOpen) {
const elapsed = Date.now() - circuit.openedAt;
if (elapsed < circuit.resetAfterMs) {
// Circuit is open, wait or throw
const waitTime = circuit.resetAfterMs - elapsed;
console.log(
`Circuit breaker open for ${service} - waiting ${waitTime}ms`
);
await this.sleep(waitTime);
}
// Reset circuit to initial state
circuit.isOpen = false;
circuit.consecutiveFailures = 0;
circuit.resetAfterMs = 30000; // Reset to initial 30 seconds
}
// Execute with retry logic
let lastError: Error | null = null;
const maxRetries = options?.skipRetry ? 0 : config.maxRetries;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await queue.add(
async () => {
return await requestFn();
},
{ priority: options?.priority ?? 0 }
);
// Success - reset failure count
circuit.consecutiveFailures = 0;
return result as T;
} catch (error: any) {
lastError = error;
// Check if it's a rate limit error
const isRateLimit =
error.response?.status === 429 ||
error.message?.includes("429") ||
error.message?.toLowerCase().includes("rate limit");
if (isRateLimit) {
circuit.consecutiveFailures++;
// Calculate backoff delay
const delay = this.calculateBackoff(
attempt,
config.baseDelay,
error
);
console.warn(
`Rate limited by ${service} (attempt ${attempt + 1}/${
maxRetries + 1
}) - backing off ${delay}ms`
);
// If too many failures, open circuit
if (circuit.consecutiveFailures >= 5) {
circuit.isOpen = true;
circuit.openedAt = Date.now();
circuit.resetAfterMs = Math.min(
60000,
circuit.resetAfterMs * 2
);
console.warn(
`Circuit breaker opened for ${service} - will reset in ${circuit.resetAfterMs}ms`
);
}
if (attempt < maxRetries) {
await this.sleep(delay);
continue;
}
}
// Non-rate-limit error or max retries reached
throw error;
}
}
throw lastError || new Error("Request failed after retries");
}
/**
* Calculate exponential backoff delay
*/
private calculateBackoff(
attempt: number,
baseDelay: number,
error?: any
): number {
// Check for Retry-After header
const retryAfter = error?.response?.headers?.["retry-after"];
if (retryAfter) {
const parsed = parseInt(retryAfter, 10);
if (!isNaN(parsed)) {
return parsed * 1000; // Convert to ms
}
}
// Exponential backoff with jitter
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
return Math.min(exponentialDelay + jitter, 60000); // Cap at 60 seconds
}
/**
* Pause all requests globally (for severe rate limiting)
*/
pauseAll(durationMs: number) {
this.globalPaused = true;
this.globalPauseUntil = Date.now() + durationMs;
console.warn(`Global rate limiter paused for ${durationMs}ms`);
}
/**
* Resume all requests
*/
resume() {
this.globalPaused = false;
this.globalPauseUntil = 0;
console.log("Global rate limiter resumed");
}
/**
* Get queue statistics
*/
getStats(): Record<ServiceName, { pending: number; size: number }> {
const stats: any = {};
for (const [service, queue] of this.queues.entries()) {
stats[service] = {
pending: queue.pending,
size: queue.size,
};
}
return stats;
}
/**
* Wait for all pending requests to complete
*/
async drain(): Promise<void> {
const promises = Array.from(this.queues.values()).map((queue) =>
queue.onIdle()
);
await Promise.all(promises);
}
/**
* Clear all pending requests
*/
clear() {
for (const queue of this.queues.values()) {
queue.clear();
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// Singleton instance
export const rateLimiter = new GlobalRateLimiter();
// Export types for use in other services
export type { ServiceName, RateLimitConfig };
+281
View File
@@ -0,0 +1,281 @@
import Parser from "rss-parser";
interface RSSPodcast {
title: string;
author?: string;
description?: string;
imageUrl?: string;
language?: string;
explicit?: boolean;
itunesId?: string;
}
interface RSSEpisode {
guid: string;
title: string;
description?: string;
audioUrl: string;
duration: number; // seconds
publishedAt: Date;
episodeNumber?: number;
season?: number;
imageUrl?: string;
fileSize?: number; // bytes
mimeType?: string;
}
interface ParsedPodcastFeed {
podcast: RSSPodcast;
episodes: RSSEpisode[];
}
class RSSParserService {
private parser: Parser;
constructor() {
this.parser = new Parser({
customFields: {
feed: [
["itunes:author", "itunesAuthor"],
["itunes:image", "itunesImage"],
["itunes:explicit", "itunesExplicit"],
["itunes:type", "itunesType"],
],
item: [
["itunes:author", "itunesAuthor"],
["itunes:duration", "itunesDuration"],
["itunes:image", "itunesImage"],
["itunes:episode", "itunesEpisode"],
["itunes:season", "itunesSeason"],
["itunes:explicit", "itunesExplicit"],
],
},
});
}
/**
* Parse an RSS podcast feed from a URL
*/
async parseFeed(feedUrl: string): Promise<ParsedPodcastFeed> {
try {
console.log(`\n [RSS PARSER] Fetching feed: ${feedUrl}`);
const feed = await this.parser.parseURL(feedUrl);
// Extract podcast metadata
const podcast: RSSPodcast = {
title: feed.title || "Unknown Podcast",
author: (feed as any).itunesAuthor || feed.author || undefined,
description: feed.description || undefined,
imageUrl: this.extractImageUrl(feed),
language: feed.language || undefined,
explicit: this.parseExplicit((feed as any).itunesExplicit),
itunesId: this.extractItunesId(feed),
};
console.log(` Podcast: ${podcast.title}`);
console.log(` Author: ${podcast.author || "Unknown"}`);
console.log(` Episodes found: ${feed.items?.length || 0}`);
// Extract episodes
const episodes: RSSEpisode[] = (feed.items || [])
.map((item) => {
try {
// Find audio enclosure
const audioEnclosure = this.findAudioEnclosure(item);
if (!audioEnclosure) {
console.warn(
` Skipping episode "${item.title}" - no audio found`
);
return null;
}
const episode: RSSEpisode = {
guid: item.guid || item.link || item.title || "",
title: item.title || "Unknown Episode",
description:
item.content ||
item.contentSnippet ||
undefined,
audioUrl: audioEnclosure.url,
duration: this.parseDuration(
(item as any).itunesDuration
),
publishedAt: item.pubDate
? new Date(item.pubDate)
: new Date(),
episodeNumber: (item as any).itunesEpisode
? parseInt((item as any).itunesEpisode)
: undefined,
season: (item as any).itunesSeason
? parseInt((item as any).itunesSeason)
: undefined,
imageUrl:
this.extractImageUrl(item) ||
podcast.imageUrl ||
undefined,
fileSize: audioEnclosure.length
? parseInt(audioEnclosure.length)
: undefined,
mimeType: audioEnclosure.type || "audio/mpeg",
};
return episode;
} catch (error: any) {
console.error(
` Error parsing episode "${item.title}":`,
error.message
);
return null;
}
})
.filter((ep): ep is RSSEpisode => ep !== null);
console.log(` Successfully parsed ${episodes.length} episodes`);
return { podcast, episodes };
} catch (error: any) {
console.error(
`\n [RSS PARSER] Failed to parse feed:`,
error.message
);
throw new Error(`Failed to parse podcast feed: ${error.message}`);
}
}
/**
* Extract image URL from feed/item
*/
private extractImageUrl(data: any): string | undefined {
// Try iTunes image first
if (data.itunesImage) {
if (typeof data.itunesImage === "string") {
return data.itunesImage;
}
if (data.itunesImage.href) {
return data.itunesImage.href;
}
if (data.itunesImage.$ && data.itunesImage.$.href) {
return data.itunesImage.$.href;
}
}
// Try standard image field
if (data.image) {
if (typeof data.image === "string") {
return data.image;
}
if (data.image.url) {
return data.image.url;
}
}
return undefined;
}
/**
* Find audio enclosure in episode
*/
private findAudioEnclosure(
item: any
): { url: string; type?: string; length?: string } | null {
// Check enclosure field
if (item.enclosure) {
const enc = item.enclosure;
if (enc.url && this.isAudioMimeType(enc.type)) {
return {
url: enc.url,
type: enc.type,
length: enc.length,
};
}
}
// Check enclosures array
if (Array.isArray(item.enclosures)) {
for (const enc of item.enclosures) {
if (enc.url && this.isAudioMimeType(enc.type)) {
return {
url: enc.url,
type: enc.type,
length: enc.length,
};
}
}
}
return null;
}
/**
* Check if MIME type is audio
*/
private isAudioMimeType(mimeType?: string): boolean {
if (!mimeType) return false;
return (
mimeType.startsWith("audio/") ||
mimeType.includes("mpeg") ||
mimeType.includes("mp3") ||
mimeType.includes("m4a")
);
}
/**
* Parse iTunes duration format
* Supports: "HH:MM:SS", "MM:SS", or just seconds
*/
private parseDuration(duration?: string): number {
if (!duration) return 0;
// If it's already a number (seconds)
const asNumber = parseInt(duration);
if (!isNaN(asNumber) && asNumber.toString() === duration) {
return asNumber;
}
// Parse time format (HH:MM:SS or MM:SS)
const parts = duration.split(":").map((p) => parseInt(p));
if (parts.length === 3) {
// HH:MM:SS
return parts[0] * 3600 + parts[1] * 60 + parts[2];
} else if (parts.length === 2) {
// MM:SS
return parts[0] * 60 + parts[1];
}
return 0;
}
/**
* Parse explicit flag
*/
private parseExplicit(explicit?: string): boolean {
if (!explicit) return false;
const lower = explicit.toLowerCase();
return lower === "yes" || lower === "true" || lower === "explicit";
}
/**
* Extract iTunes ID from feed
*/
private extractItunesId(feed: any): string | undefined {
// Try to extract from feed link (e.g., https://podcasts.apple.com/us/podcast/podcast-name/id123456789)
if (feed.link) {
const match = feed.link.match(/\/id(\d+)/);
if (match) {
return match[1];
}
}
// Try from feed image URL
if (feed.image?.url) {
const match = feed.image.url.match(/\/id(\d+)/);
if (match) {
return match[1];
}
}
return undefined;
}
}
export const rssParserService = new RSSParserService();
+384
View File
@@ -0,0 +1,384 @@
import { prisma } from "../utils/db";
import { redisClient } from "../utils/redis";
interface SearchOptions {
query: string;
limit?: number;
offset?: number;
}
interface ArtistSearchResult {
id: string;
name: string;
mbid: string;
heroUrl: string | null;
rank: number;
}
interface AlbumSearchResult {
id: string;
title: string;
artistId: string;
artistName: string;
year: number | null;
coverUrl: string | null;
rank: number;
}
interface TrackSearchResult {
id: string;
title: string;
albumId: string;
albumTitle: string;
artistId: string;
artistName: string;
duration: number;
rank: number;
}
interface PodcastSearchResult {
id: string;
title: string;
author: string | null;
description: string | null;
imageUrl: string | null;
episodeCount: number;
}
export class SearchService {
/**
* Convert user query to PostgreSQL tsquery format
* Splits on whitespace and adds prefix matching (:*)
* Example: "radio head" -> "radio:* & head:*"
*/
private queryToTsquery(query: string): string {
return query
.trim()
.split(/\s+/)
.map((term) => `${term.replace(/[^\w]/g, "")}:*`)
.join(" & ");
}
async searchArtists({
query,
limit = 20,
offset = 0,
}: SearchOptions): Promise<ArtistSearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
const tsquery = this.queryToTsquery(query);
try {
const results = await prisma.$queryRaw<ArtistSearchResult[]>`
SELECT
id,
name,
mbid,
"heroUrl",
ts_rank(search_vector, to_tsquery('english', ${tsquery})) AS rank
FROM "Artist"
WHERE search_vector @@ to_tsquery('english', ${tsquery})
ORDER BY rank DESC, name ASC
LIMIT ${limit}
OFFSET ${offset}
`;
return results;
} catch (error) {
console.error("Artist search error:", error);
// Fallback to LIKE query if full-text search fails
const results = await prisma.artist.findMany({
where: {
name: {
contains: query,
mode: "insensitive",
},
},
select: {
id: true,
name: true,
mbid: true,
heroUrl: true,
},
take: limit,
skip: offset,
orderBy: {
name: "asc",
},
});
return results.map((r) => ({ ...r, rank: 0 }));
}
}
async searchAlbums({
query,
limit = 20,
offset = 0,
}: SearchOptions): Promise<AlbumSearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
const tsquery = this.queryToTsquery(query);
try {
const results = await prisma.$queryRaw<AlbumSearchResult[]>`
SELECT
a.id,
a.title,
a."artistId",
ar.name as "artistName",
a.year,
a."coverUrl",
GREATEST(
ts_rank(a.search_vector, to_tsquery('english', ${tsquery})),
ts_rank(ar.search_vector, to_tsquery('english', ${tsquery}))
) AS rank
FROM "Album" a
LEFT JOIN "Artist" ar ON a."artistId" = ar.id
WHERE a.search_vector @@ to_tsquery('english', ${tsquery})
OR ar.search_vector @@ to_tsquery('english', ${tsquery})
ORDER BY rank DESC, a.title ASC
LIMIT ${limit}
OFFSET ${offset}
`;
return results;
} catch (error) {
console.error("Album search error:", error);
// Fallback to LIKE query - search both album title and artist name
const results = await prisma.album.findMany({
where: {
OR: [
{
title: {
contains: query,
mode: "insensitive",
},
},
{
artist: {
name: {
contains: query,
mode: "insensitive",
},
},
},
],
},
select: {
id: true,
title: true,
artistId: true,
year: true,
coverUrl: true,
artist: {
select: {
name: true,
},
},
},
take: limit,
skip: offset,
orderBy: {
title: "asc",
},
});
return results.map((r) => ({
id: r.id,
title: r.title,
artistId: r.artistId,
artistName: r.artist.name,
year: r.year,
coverUrl: r.coverUrl,
rank: 0,
}));
}
}
async searchTracks({
query,
limit = 20,
offset = 0,
}: SearchOptions): Promise<TrackSearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
const tsquery = this.queryToTsquery(query);
try {
const results = await prisma.$queryRaw<TrackSearchResult[]>`
SELECT
t.id,
t.title,
t."albumId",
t.duration,
a.title as "albumTitle",
a."artistId",
ar.name as "artistName",
ts_rank(t.search_vector, to_tsquery('english', ${tsquery})) AS rank
FROM "Track" t
LEFT JOIN "Album" a ON t."albumId" = a.id
LEFT JOIN "Artist" ar ON a."artistId" = ar.id
WHERE t.search_vector @@ to_tsquery('english', ${tsquery})
ORDER BY rank DESC, t.title ASC
LIMIT ${limit}
OFFSET ${offset}
`;
return results;
} catch (error) {
console.error("Track search error:", error);
// Fallback to LIKE query
const results = await prisma.track.findMany({
where: {
title: {
contains: query,
mode: "insensitive",
},
},
select: {
id: true,
title: true,
albumId: true,
duration: true,
album: {
select: {
title: true,
artistId: true,
artist: {
select: {
name: true,
},
},
},
},
},
take: limit,
skip: offset,
orderBy: {
title: "asc",
},
});
return results.map((r) => ({
id: r.id,
title: r.title,
albumId: r.albumId,
albumTitle: r.album.title,
artistId: r.album.artistId,
artistName: r.album.artist.name,
duration: r.duration,
rank: 0,
}));
}
}
async searchPodcasts({
query,
limit = 20,
offset = 0,
}: SearchOptions): Promise<PodcastSearchResult[]> {
if (!query || query.trim().length === 0) {
return [];
}
// Simple LIKE search for podcasts (no full-text search vector on podcasts yet)
try {
const results = await prisma.podcast.findMany({
where: {
OR: [
{
title: {
contains: query,
mode: "insensitive",
},
},
{
author: {
contains: query,
mode: "insensitive",
},
},
{
description: {
contains: query,
mode: "insensitive",
},
},
],
},
select: {
id: true,
title: true,
author: true,
description: true,
imageUrl: true,
episodeCount: true,
},
take: limit,
skip: offset,
orderBy: {
title: "asc",
},
});
return results;
} catch (error) {
console.error("Podcast search error:", error);
return [];
}
}
async searchAll({ query, limit = 10 }: SearchOptions) {
if (!query || query.trim().length === 0) {
return {
artists: [],
albums: [],
tracks: [],
podcasts: [],
};
}
// Check Redis cache first
const cacheKey = `search:all:${query}:${limit}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
console.log(`[SEARCH] Cache HIT for query: "${query}"`);
return JSON.parse(cached);
}
} catch (err) {
console.warn("[SEARCH] Redis cache read error:", err);
}
console.log(
`[SEARCH] Cache MISS for query: "${query}" - fetching from database`
);
const [artists, albums, tracks, podcasts] = await Promise.all([
this.searchArtists({ query, limit }),
this.searchAlbums({ query, limit }),
this.searchTracks({ query, limit }),
this.searchPodcasts({ query, limit }),
]);
const results = { artists, albums, tracks, podcasts };
// Cache for 1 hour (search results don't change often)
try {
await redisClient.setEx(cacheKey, 3600, JSON.stringify(results));
} catch (err) {
console.warn("[SEARCH] Redis cache write error:", err);
}
return results;
}
}
export const searchService = new SearchService();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
import axios from "axios";
/**
* Spotify Service
*
* Fetches public playlist data from Spotify using anonymous tokens.
* No API credentials required - uses Spotify's web player token endpoint.
*/
export interface SpotifyTrack {
spotifyId: string;
title: string;
artist: string;
artistId: string;
album: string;
albumId: string;
isrc: string | null;
durationMs: number;
trackNumber: number;
previewUrl: string | null;
coverUrl: string | null;
}
export interface SpotifyPlaylist {
id: string;
name: string;
description: string | null;
owner: string;
imageUrl: string | null;
trackCount: number;
tracks: SpotifyTrack[];
isPublic: boolean;
}
export interface SpotifyAlbum {
id: string;
name: string;
artist: string;
artistId: string;
imageUrl: string | null;
releaseDate: string | null;
trackCount: number;
}
export interface SpotifyPlaylistPreview {
id: string;
name: string;
description: string | null;
owner: string;
imageUrl: string | null;
trackCount: number;
}
// URL patterns
const SPOTIFY_PLAYLIST_REGEX = /(?:spotify\.com\/playlist\/|spotify:playlist:)([a-zA-Z0-9]+)/;
const SPOTIFY_ALBUM_REGEX = /(?:spotify\.com\/album\/|spotify:album:)([a-zA-Z0-9]+)/;
const SPOTIFY_TRACK_REGEX = /(?:spotify\.com\/track\/|spotify:track:)([a-zA-Z0-9]+)/;
class SpotifyService {
private anonymousToken: string | null = null;
private tokenExpiry: number = 0;
/**
* Get anonymous access token from Spotify web player
* Try multiple endpoints for reliability
*/
private async getAnonymousToken(): Promise<string | null> {
// Check if we have a valid token
if (this.anonymousToken && Date.now() < this.tokenExpiry - 60000) {
return this.anonymousToken;
}
// Try multiple endpoints
const endpoints = [
{
url: "https://open.spotify.com/get_access_token",
params: { reason: "transport", productType: "web_player" }
},
{
url: "https://open.spotify.com/get_access_token",
params: { reason: "init", productType: "embed" }
}
];
for (const endpoint of endpoints) {
try {
console.log(`Spotify: Fetching anonymous token from ${endpoint.url}...`);
const response = await axios.get(endpoint.url, {
params: endpoint.params,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Origin": "https://open.spotify.com",
"Referer": "https://open.spotify.com/",
},
timeout: 10000,
});
const token = response.data?.accessToken;
if (token) {
this.anonymousToken = token;
// Anonymous tokens last about an hour
this.tokenExpiry = Date.now() + 3600 * 1000;
console.log("Spotify: Got anonymous token");
return token;
}
} catch (error: any) {
console.log(`Spotify: Token endpoint failed (${error.response?.status || error.message})`);
}
}
console.error("Spotify: All token endpoints failed - API browsing unavailable");
return null;
}
/**
* Parse a Spotify URL and extract the type and ID
*/
parseUrl(url: string): { type: "playlist" | "album" | "track"; id: string } | null {
const playlistMatch = url.match(SPOTIFY_PLAYLIST_REGEX);
if (playlistMatch) {
return { type: "playlist", id: playlistMatch[1] };
}
const albumMatch = url.match(SPOTIFY_ALBUM_REGEX);
if (albumMatch) {
return { type: "album", id: albumMatch[1] };
}
const trackMatch = url.match(SPOTIFY_TRACK_REGEX);
if (trackMatch) {
return { type: "track", id: trackMatch[1] };
}
return null;
}
/**
* Fetch playlist via anonymous token
*/
private async fetchPlaylistViaAnonymousApi(playlistId: string): Promise<SpotifyPlaylist | null> {
const token = await this.getAnonymousToken();
if (!token) {
return await this.fetchPlaylistViaEmbedHtml(playlistId);
}
try {
console.log(`Spotify: Fetching playlist ${playlistId}...`);
const playlistResponse = await axios.get(
`https://api.spotify.com/v1/playlists/${playlistId}`,
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
params: {
fields: "id,name,description,owner.display_name,images,public,tracks.total,tracks.items(track(id,name,artists(id,name),album(id,name,images),duration_ms,track_number,preview_url,external_ids))",
},
timeout: 15000,
}
);
const playlist = playlistResponse.data;
console.log(`Spotify: Fetched playlist "${playlist.name}" with ${playlist.tracks?.items?.length || 0} tracks`);
const tracks: SpotifyTrack[] = [];
for (const item of playlist.tracks?.items || []) {
const track = item.track;
if (!track || !track.id) {
continue;
}
// Get album name, handling null, undefined, and empty strings
const albumName = track.album?.name?.trim() || "Unknown Album";
// Debug log for tracks with Unknown Album
if (albumName === "Unknown Album") {
console.log(`Spotify: Track "${track.name}" has no album data:`, JSON.stringify({
trackId: track.id,
album: track.album,
hasAlbum: !!track.album,
albumName: track.album?.name,
}));
}
tracks.push({
spotifyId: track.id,
title: track.name,
artist: track.artists?.[0]?.name || "Unknown Artist",
artistId: track.artists?.[0]?.id || "",
album: albumName,
albumId: track.album?.id || "",
isrc: track.external_ids?.isrc || null,
durationMs: track.duration_ms || 0,
trackNumber: track.track_number || 0,
previewUrl: track.preview_url || null,
coverUrl: track.album?.images?.[0]?.url || null,
});
}
console.log(`Spotify: Processed ${tracks.length} tracks`);
return {
id: playlist.id,
name: playlist.name,
description: playlist.description,
owner: playlist.owner?.display_name || "Unknown",
imageUrl: playlist.images?.[0]?.url || null,
trackCount: playlist.tracks?.total || tracks.length,
tracks,
isPublic: playlist.public ?? true,
};
} catch (error: any) {
console.error("Spotify API error:", error.response?.status, error.response?.data || error.message);
// Fallback to embed HTML parsing
return await this.fetchPlaylistViaEmbedHtml(playlistId);
}
}
/**
* Last resort: Parse embed HTML for track data
*/
private async fetchPlaylistViaEmbedHtml(playlistId: string): Promise<SpotifyPlaylist | null> {
try {
console.log("Spotify: Trying embed HTML parsing...");
const response = await axios.get(
`https://open.spotify.com/embed/playlist/${playlistId}`,
{
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
timeout: 10000,
}
);
const html = response.data;
const match = html.match(/<script id="__NEXT_DATA__" type="application\/json">([^<]+)<\/script>/);
if (!match) {
console.error("Spotify: Could not find __NEXT_DATA__ in embed HTML");
return null;
}
const data = JSON.parse(match[1]);
const playlistData = data.props?.pageProps?.state?.data?.entity
|| data.props?.pageProps?.state?.data
|| data.props?.pageProps;
if (!playlistData) {
console.error("Spotify: Could not find playlist data in embed JSON");
return null;
}
const tracks: SpotifyTrack[] = [];
const trackList = playlistData.trackList || playlistData.tracks?.items || [];
for (const item of trackList) {
const trackData = item.track || item;
// Extract primary artist - prefer artists array first element, fall back to subtitle
// subtitle often contains "Artist1, Artist2, Artist3" but we want only primary
let primaryArtist = trackData.artists?.[0]?.name;
if (!primaryArtist && trackData.subtitle) {
// Extract first artist from subtitle (before any comma)
primaryArtist = trackData.subtitle.split(",")[0].trim();
}
primaryArtist = primaryArtist || "Unknown Artist";
const embedAlbumName = trackData.album?.name || trackData.albumName || "Unknown Album";
// Debug log for tracks with Unknown Album
if (embedAlbumName === "Unknown Album") {
console.log(`Spotify Embed: Track "${trackData.title || trackData.name}" has no album data:`, JSON.stringify({
album: trackData.album,
albumName: trackData.albumName,
hasAlbum: !!trackData.album,
}));
}
tracks.push({
spotifyId: trackData.uri?.split(":")[2] || trackData.id || "",
title: trackData.title || trackData.name || "Unknown",
artist: primaryArtist,
artistId: trackData.artists?.[0]?.uri?.split(":")[2] || trackData.artists?.[0]?.id || "",
album: embedAlbumName,
albumId: trackData.album?.uri?.split(":")[2] || trackData.album?.id || "",
isrc: null,
durationMs: trackData.duration || trackData.duration_ms || 0,
trackNumber: 0,
previewUrl: null,
coverUrl: trackData.album?.images?.[0]?.url || trackData.images?.[0]?.url || null,
});
}
return {
id: playlistId,
name: playlistData.name || "Unknown Playlist",
description: playlistData.description || null,
owner: playlistData.ownerV2?.data?.name || playlistData.owner?.display_name || "Unknown",
imageUrl: playlistData.images?.items?.[0]?.sources?.[0]?.url || playlistData.images?.[0]?.url || null,
trackCount: trackList.length,
tracks,
isPublic: true,
};
} catch (error: any) {
console.error("Spotify embed HTML error:", error.message);
return null;
}
}
/**
* Fetch a playlist by ID or URL
*/
async getPlaylist(urlOrId: string): Promise<SpotifyPlaylist | null> {
// Extract ID from URL if needed
let playlistId = urlOrId;
const parsed = this.parseUrl(urlOrId);
if (parsed) {
if (parsed.type !== "playlist") {
throw new Error(`Expected playlist URL, got ${parsed.type}`);
}
playlistId = parsed.id;
}
console.log("Spotify: Fetching public playlist via anonymous token");
return await this.fetchPlaylistViaAnonymousApi(playlistId);
}
/**
* Get featured/popular playlists from Spotify
* Uses multiple fallback approaches
*/
async getFeaturedPlaylists(limit: number = 20): Promise<SpotifyPlaylistPreview[]> {
const token = await this.getAnonymousToken();
if (!token) {
console.error("Spotify: Cannot fetch featured playlists without token");
return [];
}
// Try official API first
try {
console.log("Spotify: Trying featured playlists via official API...");
const response = await axios.get(
"https://api.spotify.com/v1/browse/featured-playlists",
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
params: {
limit,
country: "US",
},
timeout: 10000,
}
);
const playlists = response.data?.playlists?.items || [];
if (playlists.length > 0) {
console.log(`Spotify: Got ${playlists.length} featured playlists via official API`);
return playlists.map((playlist: any) => ({
id: playlist.id,
name: playlist.name,
description: playlist.description || null,
owner: playlist.owner?.display_name || "Spotify",
imageUrl: playlist.images?.[0]?.url || null,
trackCount: playlist.tracks?.total || 0,
}));
}
} catch (error: any) {
console.log("Spotify: Featured playlists API failed, trying search fallback...", error.response?.status || error.message);
}
// Fallback: Search for popular playlists
try {
console.log("Spotify: Trying search fallback for featured playlists...");
// Search for popular/curated playlists
const searches = ["Today's Top Hits", "Hot Hits", "Viral Hits", "All Out", "Rock Classics", "Chill Hits"];
const allPlaylists: SpotifyPlaylistPreview[] = [];
for (const query of searches.slice(0, 3)) {
const results = await this.searchPlaylists(query, 5);
// Filter to only include Spotify-owned playlists
const spotifyOwned = results.filter(p =>
p.owner.toLowerCase() === "spotify" ||
p.owner.toLowerCase().includes("spotify")
);
allPlaylists.push(...spotifyOwned);
if (allPlaylists.length >= limit) break;
}
console.log(`Spotify: Got ${allPlaylists.length} playlists via search fallback`);
return allPlaylists.slice(0, limit);
} catch (searchError: any) {
console.error("Spotify: Search fallback also failed:", searchError.message);
return [];
}
}
/**
* Get playlists by category
*/
async getCategoryPlaylists(categoryId: string, limit: number = 20): Promise<SpotifyPlaylistPreview[]> {
const token = await this.getAnonymousToken();
if (!token) {
return [];
}
try {
console.log(`Spotify: Fetching playlists for category ${categoryId}...`);
const response = await axios.get(
`https://api.spotify.com/v1/browse/categories/${categoryId}/playlists`,
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
params: {
limit,
country: "US",
},
timeout: 10000,
}
);
const playlists = response.data?.playlists?.items || [];
return playlists.map((playlist: any) => ({
id: playlist.id,
name: playlist.name,
description: playlist.description || null,
owner: playlist.owner?.display_name || "Spotify",
imageUrl: playlist.images?.[0]?.url || null,
trackCount: playlist.tracks?.total || 0,
}));
} catch (error: any) {
console.error(`Spotify category playlists error for ${categoryId}:`, error.message);
return [];
}
}
/**
* Search for playlists on Spotify
*/
async searchPlaylists(query: string, limit: number = 20): Promise<SpotifyPlaylistPreview[]> {
const token = await this.getAnonymousToken();
if (!token) {
console.error("Spotify: Cannot search without token");
return [];
}
try {
console.log(`Spotify: Searching playlists for "${query}"...`);
const response = await axios.get(
"https://api.spotify.com/v1/search",
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json",
},
params: {
q: query,
type: "playlist",
limit,
market: "US",
},
timeout: 15000,
}
);
const playlists = response.data?.playlists?.items || [];
console.log(`Spotify: Found ${playlists.length} playlists for "${query}"`);
return playlists
.filter((playlist: any) => playlist && playlist.id) // Filter out null entries
.map((playlist: any) => ({
id: playlist.id,
name: playlist.name,
description: playlist.description || null,
owner: playlist.owner?.display_name || "Unknown",
imageUrl: playlist.images?.[0]?.url || null,
trackCount: playlist.tracks?.total || 0,
}));
} catch (error: any) {
console.error("Spotify search playlists error:", error.response?.status, error.response?.data || error.message);
// If unauthorized, try refreshing token and retry once
if (error.response?.status === 401) {
console.log("Spotify: Token expired, refreshing...");
this.anonymousToken = null;
this.tokenExpiry = 0;
const newToken = await this.getAnonymousToken();
if (newToken) {
try {
const retryResponse = await axios.get(
"https://api.spotify.com/v1/search",
{
headers: {
Authorization: `Bearer ${newToken}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
params: { q: query, type: "playlist", limit, market: "US" },
timeout: 15000,
}
);
const retryPlaylists = retryResponse.data?.playlists?.items || [];
return retryPlaylists
.filter((p: any) => p && p.id)
.map((p: any) => ({
id: p.id,
name: p.name,
description: p.description || null,
owner: p.owner?.display_name || "Unknown",
imageUrl: p.images?.[0]?.url || null,
trackCount: p.tracks?.total || 0,
}));
} catch (retryError) {
console.error("Spotify: Retry also failed");
}
}
}
return [];
}
}
/**
* Get available browse categories
*/
async getCategories(limit: number = 20): Promise<Array<{ id: string; name: string; imageUrl: string | null }>> {
const token = await this.getAnonymousToken();
if (!token) {
return [];
}
try {
const response = await axios.get(
"https://api.spotify.com/v1/browse/categories",
{
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
params: {
limit,
country: "US",
},
timeout: 10000,
}
);
return (response.data?.categories?.items || []).map((cat: any) => ({
id: cat.id,
name: cat.name,
imageUrl: cat.icons?.[0]?.url || null,
}));
} catch (error: any) {
console.error("Spotify categories error:", error.message);
return [];
}
}
}
export const spotifyService = new SpotifyService();
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
import axios, { AxiosInstance } from "axios";
import { redisClient } from "../utils/redis";
interface WikidataResult {
summary?: string;
heroUrl?: string;
}
class WikidataService {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
timeout: 10000,
headers: {
"User-Agent":
"Lidify/1.0.0 (https://github.com/Chevron7Locked/lidify)",
},
});
}
async getArtistInfo(
artistName: string,
mbid: string
): Promise<WikidataResult> {
const cacheKey = `wikidata:${mbid}`;
try {
const cached = await redisClient.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.warn("Redis get error:", err);
}
try {
// Step 1: Query Wikidata for the MusicBrainz ID
const wikidataId = await this.getWikidataIdFromMBID(mbid);
if (!wikidataId) {
console.log(`No Wikidata entry found for ${artistName}`);
return {};
}
// Step 2: Get Wikipedia article and image
const [summary, heroUrl] = await Promise.all([
this.getWikipediaSummary(wikidataId),
this.getWikidataImage(wikidataId),
]);
const result: WikidataResult = { summary, heroUrl };
// Cache for 30 days
try {
await redisClient.setEx(
cacheKey,
2592000,
JSON.stringify(result)
);
} catch (err) {
console.warn("Redis set error:", err);
}
return result;
} catch (error) {
console.error(`Wikidata fetch failed for ${artistName}:`, error);
return {};
}
}
private async getWikidataIdFromMBID(mbid: string): Promise<string | null> {
const sparqlQuery = `
SELECT ?item WHERE {
?item wdt:P434 "${mbid}" .
}
LIMIT 1
`;
const response = await axios.get("https://query.wikidata.org/sparql", {
params: {
query: sparqlQuery,
format: "json",
},
headers: {
"User-Agent": "Lidify/1.0.0",
},
});
const bindings = response.data.results?.bindings || [];
if (bindings.length === 0) return null;
const itemUrl = bindings[0].item.value;
return itemUrl.split("/").pop() || null;
}
private async getWikipediaSummary(
wikidataId: string
): Promise<string | undefined> {
try {
// Get English Wikipedia article title
const response = await axios.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
);
const entity = response.data.entities?.[wikidataId];
const enWikiTitle = entity?.sitelinks?.enwiki?.title;
if (!enWikiTitle) return undefined;
// Get article summary from Wikipedia API
const summaryResponse = await axios.get(
"https://en.wikipedia.org/api/rest_v1/page/summary/" +
encodeURIComponent(enWikiTitle)
);
return summaryResponse.data.extract;
} catch (error) {
console.error(
`Failed to get Wikipedia summary for ${wikidataId}:`,
error
);
return undefined;
}
}
private async getWikidataImage(
wikidataId: string
): Promise<string | undefined> {
try {
const response = await axios.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
);
const entity = response.data.entities?.[wikidataId];
const imageProperty = entity?.claims?.P18; // P18 is "image"
if (!imageProperty || imageProperty.length === 0) return undefined;
const imageName = imageProperty[0].mainsnak?.datavalue?.value;
if (!imageName) return undefined;
// Convert to Wikimedia Commons URL
const fileName = imageName.replace(/ /g, "_");
const md5 = require("crypto")
.createHash("md5")
.update(fileName)
.digest("hex");
const url = `https://upload.wikimedia.org/wikipedia/commons/${
md5[0]
}/${md5[0]}${md5[1]}/${encodeURIComponent(fileName)}`;
return url;
} catch (error) {
console.error(
`Failed to get Wikidata image for ${wikidataId}:`,
error
);
return undefined;
}
}
}
export const wikidataService = new WikidataService();
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
import * as fuzz from "fuzzball";
/**
* Utility functions for normalizing artist and album names
* to handle case-sensitivity and other variations
*/
/**
* Canonical name and MBID for compilation/various artists
*/
export const VARIOUS_ARTISTS_CANONICAL = "Various Artists";
export const VARIOUS_ARTISTS_MBID = "89ad4ac3-39f7-470e-963a-56509c546377";
/**
* Check if an artist name is a variation of "Various Artists"
* and return the canonical form if so.
*
* Uses regex for flexible matching instead of exhaustive list.
* Covers: VA, V.A., V/A, V.A, Various, Various Artist(s), <Various Artists>, etc.
*/
export function canonicalizeVariousArtists(name: string): string {
// Strip angle brackets and trim
const cleaned = name.trim().replace(/^<|>$/g, '');
// Case-insensitive regex patterns for Various Artists variations
// Pattern 1: VA, V.A., V/A, V.A (with optional dots/slashes)
// Pattern 2: Various, Various Artist, Various Artists
const vaPattern = /^v\.?\s*[\/.]?\s*a\.?$/i;
const variousPattern = /^various(\s+artists?)?$/i;
if (vaPattern.test(cleaned) || variousPattern.test(cleaned)) {
return VARIOUS_ARTISTS_CANONICAL;
}
return name;
}
/**
* Check if a platform-specific artist ID is Various Artists
*/
export function isVariousArtistsById(platform: 'deezer' | 'spotify', id: string | number): boolean {
if (platform === 'deezer' && String(id) === '5080') {
return true;
}
// Add other platform IDs as needed
return false;
}
/**
* Strip diacritics/accents from a string
* e.g., "Ólafur" "Olafur", "Björk" "Bjork"
*/
function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
/**
* Check if a string contains any diacritics/accents
* Used to prefer the accented version when merging duplicates
*/
export function hasDiacritics(str: string): boolean {
return str !== stripDiacritics(str);
}
/**
* Given two artist names, return the "preferred" one
* Prefers the accented version as it's likely the official spelling
* e.g., "Olafur Arnalds" vs "Ólafur Arnalds" "Ólafur Arnalds"
*/
export function getPreferredArtistName(name1: string, name2: string): string {
const has1 = hasDiacritics(name1);
const has2 = hasDiacritics(name2);
// If one has accents and the other doesn't, prefer the accented one
if (has2 && !has1) return name2;
if (has1 && !has2) return name1;
// If both or neither have accents, prefer the longer/more complete one
return name1.length >= name2.length ? name1 : name2;
}
/**
* Normalize an artist name for case-insensitive comparison
* - Converts to lowercase
* - Trims whitespace
* - Strips diacritics/accents (Ólafur olafur)
* - Normalizes "&" to "and" (Of Mice & Men of mice and men)
* - Normalizes common variations
* - This ensures "Olafur Arnalds" and "Ólafur Arnalds" match
* - This ensures "Of Mice & Men" and "Of Mice And Men" match
*/
export function normalizeArtistName(name: string): string {
let normalized = stripDiacritics(name.trim().toLowerCase());
// Normalize "&" to "and" (handles "Of Mice & Men" vs "Of Mice And Men")
normalized = normalized.replace(/\s*&\s*/g, ' and ');
// Normalize multiple spaces to single space
normalized = normalized.replace(/\s+/g, ' ');
return normalized.trim();
}
/**
* Normalize an album title for case-insensitive comparison
* - Converts to lowercase
* - Trims whitespace
*/
export function normalizeAlbumTitle(title: string): string {
return title.trim().toLowerCase();
}
/**
* Check if two artist names are similar enough to be considered the same
* Uses fuzzy matching to catch typos like "the weeknd" vs "the weekend"
* @param name1 First artist name
* @param name2 Second artist name
* @param threshold Similarity threshold (0-100), default 95
* @returns true if names are similar enough
*/
export function areArtistNamesSimilar(
name1: string,
name2: string,
threshold: number = 95
): boolean {
// First normalize both names
const normalized1 = normalizeArtistName(name1);
const normalized2 = normalizeArtistName(name2);
// If they're exactly equal after normalization, return true
if (normalized1 === normalized2) {
return true;
}
// Use fuzzy matching to catch typos
const similarity = fuzz.ratio(normalized1, normalized2);
return similarity >= threshold;
}
/**
* Find the best matching artist from a list of candidates
* @param targetName The name to match
* @param candidates List of candidate artist names
* @param threshold Minimum similarity score (0-100), default 95
* @returns The best matching artist name, or null if no good match
*/
export function findBestArtistMatch(
targetName: string,
candidates: string[],
threshold: number = 95
): string | null {
const normalizedTarget = normalizeArtistName(targetName);
let bestMatch: string | null = null;
let bestScore = 0;
for (const candidate of candidates) {
const normalizedCandidate = normalizeArtistName(candidate);
const score = fuzz.ratio(normalizedTarget, normalizedCandidate);
if (score >= threshold && score > bestScore) {
bestScore = score;
bestMatch = candidate;
}
}
return bestMatch;
}
+235
View File
@@ -0,0 +1,235 @@
import sharp from "sharp";
interface ColorPalette {
vibrant: string;
darkVibrant: string;
lightVibrant: string;
muted: string;
darkMuted: string;
lightMuted: string;
}
interface RGBColor {
r: number;
g: number;
b: number;
}
/**
* Calculate saturation of an RGB color
*/
function getSaturation(r: number, g: number, b: number): number {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
return max === 0 ? 0 : delta / max;
}
/**
* Convert RGB to hex color
*/
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((x) => {
const hex = x.toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
);
}
/**
* Extract dominant colors from an image buffer using sharp
*/
export async function extractColorsFromImage(
imageBuffer: Buffer
): Promise<ColorPalette> {
try {
// Resize image to 100x100 for performance
const { data, info } = await sharp(imageBuffer)
.resize(100, 100, { fit: "inside" })
.raw()
.toBuffer({ resolveWithObject: true });
const pixels = data;
const colorMap = new Map<
string,
{ count: number; saturation: number }
>();
// Count color frequencies
for (let i = 0; i < pixels.length; i += info.channels) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = info.channels === 4 ? pixels[i + 3] : 255;
// Skip transparent pixels
if (a < 125) continue;
// Skip extremely dark and extremely light pixels
const brightness = (r + g + b) / 3;
if (brightness < 15 || brightness > 240) continue;
// Calculate saturation
const saturation = getSaturation(r, g, b);
// Reduce color precision for better grouping
const reducedR = Math.floor(r / 15) * 15;
const reducedG = Math.floor(g / 15) * 15;
const reducedB = Math.floor(b / 15) * 15;
const key = `${reducedR},${reducedG},${reducedB}`;
const existing = colorMap.get(key);
if (existing) {
existing.count += 1;
existing.saturation = Math.max(existing.saturation, saturation);
} else {
colorMap.set(key, { count: 1, saturation });
}
}
// Sort colors by weighted score
const sortedColors = Array.from(colorMap.entries())
.sort((a, b) => {
const scoreA = a[1].count * (1 + a[1].saturation * 2);
const scoreB = b[1].count * (1 + b[1].saturation * 2);
return scoreB - scoreA;
})
.map(([color]) => {
const [r, g, b] = color.split(",").map(Number);
return { r, g, b };
});
if (sortedColors.length === 0) {
// Fallback colors
return {
vibrant: "#1db954",
darkVibrant: "#121212",
lightVibrant: "#181818",
muted: "#535353",
darkMuted: "#121212",
lightMuted: "#b3b3b3",
};
}
// Find vibrant color
const vibrantColor = sortedColors.reduce((prev, curr) => {
const prevSat = getSaturation(prev.r, prev.g, prev.b);
const currSat = getSaturation(curr.r, curr.g, curr.b);
return currSat > prevSat ? curr : prev;
});
// Boost vibrant color if too dark
const vibrantBrightness =
(vibrantColor.r + vibrantColor.g + vibrantColor.b) / 3;
let boostFactor: number;
if (vibrantBrightness < 30) {
boostFactor = 10.0;
} else if (vibrantBrightness < 60) {
boostFactor = 5.0;
} else if (vibrantBrightness < 100) {
boostFactor = 3.0;
} else if (vibrantBrightness < 140) {
boostFactor = 2.0;
} else {
boostFactor = 1.3;
}
let boostedVibrant = {
r: Math.min(255, Math.floor(vibrantColor.r * boostFactor)),
g: Math.min(255, Math.floor(vibrantColor.g * boostFactor)),
b: Math.min(255, Math.floor(vibrantColor.b * boostFactor)),
};
// Ensure minimum brightness
const boostedBrightness =
(boostedVibrant.r + boostedVibrant.g + boostedVibrant.b) / 3;
if (boostedBrightness < 80) {
const addAmount = 80 - boostedBrightness;
boostedVibrant = {
r: Math.min(255, boostedVibrant.r + addAmount),
g: Math.min(255, boostedVibrant.g + addAmount),
b: Math.min(255, boostedVibrant.b + addAmount),
};
}
// Dark vibrant
const darkVibrantColor = {
r: Math.floor(vibrantColor.r * 0.6),
g: Math.floor(vibrantColor.g * 0.6),
b: Math.floor(vibrantColor.b * 0.6),
};
// Light vibrant
const lightVibrantColor = {
r: Math.min(255, Math.floor(boostedVibrant.r * 1.2)),
g: Math.min(255, Math.floor(boostedVibrant.g * 1.2)),
b: Math.min(255, Math.floor(boostedVibrant.b * 1.2)),
};
// Find muted color
const mutedColor = sortedColors.reduce((prev, curr) => {
const prevSat = getSaturation(prev.r, prev.g, prev.b);
const currSat = getSaturation(curr.r, curr.g, curr.b);
return currSat < prevSat ? curr : prev;
});
// Dark muted
const darkMutedColor = {
r: Math.floor(mutedColor.r * 0.4),
g: Math.floor(mutedColor.g * 0.4),
b: Math.floor(mutedColor.b * 0.4),
};
// Light muted
const lightMutedColor = {
r: Math.min(255, Math.floor(mutedColor.r * 1.5)),
g: Math.min(255, Math.floor(mutedColor.g * 1.5)),
b: Math.min(255, Math.floor(mutedColor.b * 1.5)),
};
return {
vibrant: rgbToHex(
boostedVibrant.r,
boostedVibrant.g,
boostedVibrant.b
),
darkVibrant: rgbToHex(
darkVibrantColor.r,
darkVibrantColor.g,
darkVibrantColor.b
),
lightVibrant: rgbToHex(
lightVibrantColor.r,
lightVibrantColor.g,
lightVibrantColor.b
),
muted: rgbToHex(mutedColor.r, mutedColor.g, mutedColor.b),
darkMuted: rgbToHex(
darkMutedColor.r,
darkMutedColor.g,
darkMutedColor.b
),
lightMuted: rgbToHex(
lightMutedColor.r,
lightMutedColor.g,
lightMutedColor.b
),
};
} catch (error) {
console.error("[ColorExtractor] Failed to extract colors:", error);
// Return fallback colors
return {
vibrant: "#1db954",
darkVibrant: "#121212",
lightVibrant: "#181818",
muted: "#535353",
darkMuted: "#121212",
lightMuted: "#b3b3b3",
};
}
}
+132
View File
@@ -0,0 +1,132 @@
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
import { AppError, ErrorCode, ErrorCategory } from "./errors";
import ffmpegPath from "@ffmpeg-installer/ffmpeg";
import { getSystemSettings } from "./systemSettings";
export interface MusicConfig {
musicPath: string;
transcodeCachePath: string;
transcodeCacheMaxGb: number;
}
/**
* Validate and load music configuration
*/
export async function validateMusicConfig(): Promise<MusicConfig> {
// Get system settings to use configured paths
const settings = await getSystemSettings();
// Get music path - prefer environment variable if SystemSettings has default value
let musicPath = process.env.MUSIC_PATH || settings?.musicPath || "/music";
// If settings has a non-default path, prefer that over environment
if (settings?.musicPath && settings.musicPath !== "/music") {
musicPath = settings.musicPath;
}
// VALIDATE MUSIC PATH EXISTS
if (!fs.existsSync(musicPath)) {
throw new AppError(
ErrorCode.MUSIC_PATH_NOT_ACCESSIBLE,
ErrorCategory.FATAL,
`Music path does not exist: ${musicPath}. Please check MUSIC_PATH environment variable or SystemSettings.`
);
}
// VALIDATE MUSIC PATH IS READABLE
try {
fs.accessSync(musicPath, fs.constants.R_OK);
} catch {
throw new AppError(
ErrorCode.MUSIC_PATH_NOT_ACCESSIBLE,
ErrorCategory.FATAL,
`Music path not readable: ${musicPath}. Check file permissions.`
);
}
// Get transcode cache path
const transcodeCachePath =
process.env.TRANSCODE_CACHE_PATH ||
path.join(process.cwd(), "cache", "transcodes");
// VALIDATE TRANSCODE CACHE PATH
// Create if doesn't exist
if (!fs.existsSync(transcodeCachePath)) {
try {
fs.mkdirSync(transcodeCachePath, { recursive: true });
console.log(
`Created transcode cache directory: ${transcodeCachePath}`
);
} catch (err: any) {
throw new AppError(
ErrorCode.TRANSCODE_CACHE_NOT_WRITABLE,
ErrorCategory.FATAL,
`Cannot create transcode cache directory: ${transcodeCachePath}`,
{ originalError: err.message }
);
}
}
// Validate writable
try {
fs.accessSync(transcodeCachePath, fs.constants.W_OK);
} catch {
throw new AppError(
ErrorCode.TRANSCODE_CACHE_NOT_WRITABLE,
ErrorCategory.FATAL,
`Transcode cache not writable: ${transcodeCachePath}. Check file permissions.`
);
}
// Get cache size limit from SystemSettings or fallback to env/default
const transcodeCacheMaxGb =
settings?.transcodeCacheMaxGb ||
parseInt(process.env.TRANSCODE_CACHE_MAX_GB || "10", 10);
if (isNaN(transcodeCacheMaxGb) || transcodeCacheMaxGb < 1) {
throw new AppError(
ErrorCode.INVALID_CONFIG,
ErrorCategory.FATAL,
`Invalid transcode cache size: must be a positive integer. Got: ${transcodeCacheMaxGb}`
);
}
// VALIDATE BUNDLED FFMPEG (from @ffmpeg-installer/ffmpeg)
try {
// Check if bundled FFmpeg binary exists
if (!fs.existsSync(ffmpegPath.path)) {
throw new Error(`Bundled FFmpeg not found at: ${ffmpegPath.path}`);
}
// Verify it's executable by running version check
const result = execSync(`"${ffmpegPath.path}" -version`, {
encoding: "utf8",
});
if (!result.includes("ffmpeg version")) {
throw new Error("Invalid ffmpeg output");
}
console.log(`FFmpeg detected (bundled): ${result.split("\n")[0]}`);
console.log(` FFmpeg path: ${ffmpegPath.path}`);
} catch (err: any) {
console.warn(
" Bundled FFmpeg not available. Transcoding will not be available."
);
console.warn(` Error: ${err.message}`);
console.warn(" Original quality streaming will still work.");
// Don't throw - allow server to start without FFmpeg
}
console.log("Music configuration validated successfully");
console.log(` Music path: ${musicPath}`);
console.log(` Transcode cache: ${transcodeCachePath}`);
console.log(` Cache limit: ${transcodeCacheMaxGb} GB`);
return {
musicPath,
transcodeCachePath,
transcodeCacheMaxGb,
};
}
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+139
View File
@@ -0,0 +1,139 @@
import { writeFileSync, appendFileSync } from "fs";
import { join } from "path";
/**
* Logger for Discover Weekly generation that writes to both console and file
* Uses monkey-patching to intercept all console.log/error calls
*/
class DiscoverLogger {
private logFilePath: string;
private isLogging: boolean = false;
private originalConsoleLog: typeof console.log;
private originalConsoleError: typeof console.error;
constructor() {
// Create log file with timestamp
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, -5);
this.logFilePath = join(
process.cwd(),
"logs",
`discover-${timestamp}.log`
);
// Store original console methods
this.originalConsoleLog = console.log;
this.originalConsoleError = console.error;
}
/**
* Start intercepting console output and write to file
*/
start(userId: string) {
this.isLogging = true;
const header = `
========================================
Discover Weekly Generation Log
User ID: ${userId}
Started: ${new Date().toISOString()}
========================================
`;
writeFileSync(this.logFilePath, header, "utf-8");
this.originalConsoleLog(` Logging to: ${this.logFilePath}`);
// Monkey-patch console.log
console.log = (...args: any[]) => {
this.originalConsoleLog(...args);
if (this.isLogging) {
const timestamp = new Date().toISOString().slice(11, 19); // HH:MM:SS
const message = args
.map((arg) =>
typeof arg === "object"
? JSON.stringify(arg)
: String(arg)
)
.join(" ");
appendFileSync(
this.logFilePath,
`[${timestamp}] ${message}\n`,
"utf-8"
);
}
};
// Monkey-patch console.error
console.error = (...args: any[]) => {
this.originalConsoleError(...args);
if (this.isLogging) {
const timestamp = new Date().toISOString().slice(11, 19); // HH:MM:SS
const message = args
.map((arg) =>
typeof arg === "object"
? JSON.stringify(arg)
: String(arg)
)
.join(" ");
appendFileSync(
this.logFilePath,
`[${timestamp}] ERROR: ${message}\n`,
"utf-8"
);
}
};
}
/**
* Stop intercepting console output
*/
end() {
if (this.isLogging) {
const footer = `
========================================
Generation Complete
Ended: ${new Date().toISOString()}
========================================
`;
appendFileSync(this.logFilePath, footer, "utf-8");
// Restore original console methods
console.log = this.originalConsoleLog;
console.error = this.originalConsoleError;
this.originalConsoleLog(`\nFull log saved to: ${this.logFilePath}`);
this.isLogging = false;
}
}
/**
* Get the current log file path
*/
getLogPath(): string {
return this.logFilePath;
}
}
// Create singleton instance
let currentLogger: DiscoverLogger | null = null;
/**
* Get or create a logger instance
*/
export function getDiscoverLogger(): DiscoverLogger {
if (!currentLogger) {
currentLogger = new DiscoverLogger();
}
return currentLogger;
}
/**
* Reset logger (creates a new instance for next generation)
*/
export function resetDiscoverLogger() {
if (currentLogger) {
currentLogger.end();
}
currentLogger = null;
}
+100
View File
@@ -0,0 +1,100 @@
import crypto from "crypto";
const ALGORITHM = "aes-256-cbc";
// Default key for development - users should set their own for production
const DEFAULT_ENCRYPTION_KEY = "default-encryption-key-change-me";
// Track if we've warned about using the default key
let hasWarnedAboutDefaultKey = false;
/**
* Get the encryption key from environment, properly sized for AES-256
* Falls back to a default key for development but logs a warning
*/
function getEncryptionKey(): Buffer {
let key = process.env.SETTINGS_ENCRYPTION_KEY;
if (!key || key === DEFAULT_ENCRYPTION_KEY) {
if (!hasWarnedAboutDefaultKey) {
console.warn(
"[SECURITY] Using default encryption key. Set SETTINGS_ENCRYPTION_KEY in production."
);
hasWarnedAboutDefaultKey = true;
}
key = DEFAULT_ENCRYPTION_KEY;
}
if (key.length < 32) {
// Pad with zeros if too short
return Buffer.from(key.padEnd(32, "0"));
}
// Truncate if too long
return Buffer.from(key.slice(0, 32));
}
/**
* Encrypt a string using AES-256-CBC
* Returns empty string for empty/null input
*/
export function encrypt(text: string): string {
if (!text) return "";
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, getEncryptionKey(), iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString("hex") + ":" + encrypted.toString("hex");
}
/**
* Decrypt a string that was encrypted with the encrypt function
* Returns empty string for empty/null input
* Returns original text if decryption fails (for backwards compatibility with unencrypted data)
*/
export function decrypt(text: string): string {
if (!text) return "";
try {
const parts = text.split(":");
if (parts.length < 2) {
// Not in expected format, return as-is (might be unencrypted)
return text;
}
const iv = Buffer.from(parts[0], "hex");
const encryptedText = Buffer.from(parts.slice(1).join(":"), "hex");
const decipher = crypto.createDecipheriv(
ALGORITHM,
getEncryptionKey(),
iv
);
let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
} catch (error: any) {
// If it's a decryption error (wrong key), throw so callers know the value is corrupt
if (error.code === 'ERR_OSSL_BAD_DECRYPT') {
throw error;
}
// For other errors, log and return original (might be unencrypted)
console.error("Decryption error:", error);
return text;
}
}
/**
* Encrypt a field value, returning null for empty/null values
* Useful for database fields that should store null instead of empty encrypted strings
*/
export function encryptField(value: string | null | undefined): string | null {
if (!value || value.trim() === "") return null;
return encrypt(value);
}
/**
* Decrypt a field value, returning null for null values
* Returns empty string for empty input
*/
export function decryptField(value: string | null | undefined): string | null {
if (value === null || value === undefined) return null;
return decrypt(value);
}
+104
View File
@@ -0,0 +1,104 @@
import fs from "fs";
import path from "path";
/**
* Writes key-value pairs to .env file
* Preserves existing variables not in the provided map
*/
export async function writeEnvFile(
variables: Record<string, string | null | undefined>
): Promise<void> {
// Write to project root .env (parent of backend dir) for docker-compose
const envPath = path.resolve(process.cwd(), "..", ".env");
// Read existing .env
let existingContent = "";
const existingVars = new Map<string, string>();
try {
existingContent = fs.readFileSync(envPath, "utf-8");
// Parse existing variables
existingContent.split("\n").forEach((line) => {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith("#")) {
const [key, ...valueParts] = trimmed.split("=");
if (key) {
existingVars.set(key.trim(), valueParts.join("="));
}
}
});
} catch (error) {
console.log("No existing .env file, creating new one");
}
// Update with new values
Object.entries(variables).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
existingVars.set(key, value);
}
});
// Build new .env content
const lines: string[] = [
"# Lidify Environment Variables",
`# Auto-generated on ${new Date().toISOString()}`,
"",
];
// Group variables by category
const categories = {
"Database & Redis": ["DATABASE_URL", "REDIS_URL"],
Server: ["PORT", "NODE_ENV", "SESSION_SECRET", "ALLOWED_ORIGINS"],
Lidarr: ["LIDARR_ENABLED", "LIDARR_URL", "LIDARR_API_KEY"],
"Last.fm": ["LASTFM_API_KEY", "LASTFM_API_SECRET"],
"Fanart.tv": ["FANART_API_KEY"],
OpenAI: ["OPENAI_API_KEY"],
Audiobookshelf: ["AUDIOBOOKSHELF_URL", "AUDIOBOOKSHELF_API_KEY"],
Soulseek: ["SLSKD_SOULSEEK_USERNAME", "SLSKD_SOULSEEK_PASSWORD"],
"VPN (Mullvad)": [
"MULLVAD_PRIVATE_KEY",
"MULLVAD_ADDRESSES",
"MULLVAD_SERVER_CITY",
],
"Docker Paths": ["MUSIC_PATH", "DOWNLOAD_PATH"],
Security: ["SETTINGS_ENCRYPTION_KEY"],
};
const writtenKeys = new Set<string>();
// Write categorized variables
Object.entries(categories).forEach(([category, keys]) => {
const categoryVars: string[] = [];
keys.forEach((key) => {
if (existingVars.has(key)) {
const value = existingVars.get(key);
categoryVars.push(`${key}=${value}`);
writtenKeys.add(key);
}
});
if (categoryVars.length > 0) {
lines.push("", `# ${category}`, ...categoryVars);
}
});
// Write uncategorized variables
const uncategorized: string[] = [];
existingVars.forEach((value, key) => {
if (!writtenKeys.has(key)) {
uncategorized.push(`${key}=${value}`);
}
});
if (uncategorized.length > 0) {
lines.push("", "# Other Variables", ...uncategorized);
}
lines.push(""); // Trailing newline
// Write to file
fs.writeFileSync(envPath, lines.join("\n"), "utf-8");
console.log(`.env file updated with ${existingVars.size} variables`);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Error categories for classification
*/
export enum ErrorCategory {
RECOVERABLE = "RECOVERABLE", // Retry might succeed
TRANSIENT = "TRANSIENT", // Temporary issue, will resolve
FATAL = "FATAL", // Cannot continue
}
/**
* Error codes for specific error types
*/
export enum ErrorCode {
// Configuration errors
MUSIC_PATH_NOT_ACCESSIBLE = "MUSIC_PATH_NOT_ACCESSIBLE",
TRANSCODE_CACHE_NOT_WRITABLE = "TRANSCODE_CACHE_NOT_WRITABLE",
FFMPEG_NOT_FOUND = "FFMPEG_NOT_FOUND",
INVALID_CONFIG = "INVALID_CONFIG",
// File system errors
FILE_NOT_FOUND = "FILE_NOT_FOUND",
FILE_READ_ERROR = "FILE_READ_ERROR",
DISK_FULL = "DISK_FULL",
PERMISSION_DENIED = "PERMISSION_DENIED",
// Transcoding errors
TRANSCODE_FAILED = "TRANSCODE_FAILED",
TRANSCODE_TIMEOUT = "TRANSCODE_TIMEOUT",
UNSUPPORTED_FORMAT = "UNSUPPORTED_FORMAT",
// Metadata errors
METADATA_PARSE_ERROR = "METADATA_PARSE_ERROR",
CORRUPT_FILE = "CORRUPT_FILE",
// Database errors
DB_CONNECTION_ERROR = "DB_CONNECTION_ERROR",
DB_QUERY_ERROR = "DB_QUERY_ERROR",
}
/**
* Custom application error class
*/
export class AppError extends Error {
constructor(
public code: ErrorCode,
public category: ErrorCategory,
message: string,
public details?: any
) {
super(message);
this.name = "AppError";
Object.setPrototypeOf(this, AppError.prototype);
}
toJSON() {
return {
name: this.name,
code: this.code,
category: this.category,
message: this.message,
details: this.details,
};
}
}
/**
* Check if an error is recoverable
*/
export function isRecoverable(error: any): boolean {
if (error instanceof AppError) {
return error.category === ErrorCategory.RECOVERABLE;
}
return false;
}
/**
* Check if an error is transient
*/
export function isTransient(error: any): boolean {
if (error instanceof AppError) {
return error.category === ErrorCategory.TRANSIENT;
}
return false;
}
/**
* Wrap a Node.js error in an AppError
*/
export function wrapNodeError(err: any, context: string): AppError {
if (err.code === "ENOENT") {
return new AppError(
ErrorCode.FILE_NOT_FOUND,
ErrorCategory.RECOVERABLE,
`File not found: ${context}`,
{ originalError: err.message }
);
}
if (err.code === "EACCES" || err.code === "EPERM") {
return new AppError(
ErrorCode.PERMISSION_DENIED,
ErrorCategory.FATAL,
`Permission denied: ${context}`,
{ originalError: err.message }
);
}
if (err.code === "ENOSPC") {
return new AppError(
ErrorCode.DISK_FULL,
ErrorCategory.TRANSIENT,
`Disk full: ${context}`,
{ originalError: err.message }
);
}
// Generic file read error
return new AppError(
ErrorCode.FILE_READ_ERROR,
ErrorCategory.RECOVERABLE,
`Failed to read file: ${context}`,
{ originalError: err.message }
);
}
+299
View File
@@ -0,0 +1,299 @@
import * as fs from 'fs';
import * as path from 'path';
/**
* Dedicated logger for Spotify Import and Playlist operations.
*
* In Docker, the backend runs under /app and typically mounts /app/logs.
* This logger defaults to writing under ./logs/playlists (relative to process.cwd()).
*
* Override with PLAYLIST_LOG_DIR if you want a different location.
*
* Log files:
* - import_<jobId>_<timestamp>.log - Per-job detailed log
* - session.log - Current session unified log (cleared on restart)
* - events.log - Persistent event log
*/
const LOGS_DIR = process.env.PLAYLIST_LOG_DIR
? path.resolve(process.env.PLAYLIST_LOG_DIR)
: path.join(process.cwd(), 'logs', 'playlists');
const SESSION_LOG = path.join(LOGS_DIR, 'session.log');
// Clear session log on module load (fresh start)
let sessionInitialized = false;
// Ensure logs directory exists
function ensureLogsDir(): void {
try {
fs.mkdirSync(LOGS_DIR, { recursive: true });
} catch (error) {
console.error('Failed to create playlist logs directory:', {
logsDir: LOGS_DIR,
error,
});
}
}
// Initialize session log (clear previous session)
function initSessionLog(): void {
if (sessionInitialized) return;
sessionInitialized = true;
ensureLogsDir();
const header = `
================================================================================
SPOTIFY IMPORT SESSION LOG
Started: ${new Date().toISOString()}
================================================================================
`;
try {
fs.writeFileSync(SESSION_LOG, header);
} catch (error) {
console.error('Failed to initialize session log:', error);
}
}
// Write to session log (unified log for all components)
function writeToSessionLog(component: string, level: string, message: string): void {
initSessionLog();
const timestamp = new Date().toISOString().split('T')[1].replace('Z', '');
const line = `[${timestamp}] [${component}] [${level}] ${message}\n`;
try {
fs.appendFileSync(SESSION_LOG, line);
} catch (error) {
// Silently fail - don't spam console
}
}
/**
* Get the path to the current session log
*/
export function getSessionLogPath(): string {
ensureLogsDir();
return SESSION_LOG;
}
/**
* Read the current session log contents
*/
export function readSessionLog(): string {
try {
if (fs.existsSync(SESSION_LOG)) {
return fs.readFileSync(SESSION_LOG, 'utf-8');
}
return 'No session log found';
} catch (error) {
return `Error reading session log: ${error}`;
}
}
/**
* Log a message from any component to the unified session log
* Use this for SLSKD, organize, etc.
*/
export function sessionLog(component: string, message: string, level: 'INFO' | 'WARN' | 'ERROR' | 'DEBUG' = 'INFO'): void {
writeToSessionLog(component, level, message);
// Also log to console with component prefix
const prefix = `[${component}]`;
if (level === 'ERROR') {
console.error(prefix, message);
} else {
console.log(prefix, message);
}
}
function getTimestamp(): string {
return new Date().toISOString();
}
function formatLogLine(level: string, message: string): string {
return `[${getTimestamp()}] [${level}] ${message}\n`;
}
class PlaylistLogger {
private jobId: string;
private logFile: string;
private buffer: string[] = [];
constructor(jobId: string) {
this.jobId = jobId;
ensureLogsDir();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
this.logFile = path.join(LOGS_DIR, `import_${jobId}_${timestamp}.log`);
// Write header
this.write('INFO', `=== SPOTIFY IMPORT JOB: ${jobId} ===`);
}
private write(level: string, message: string): void {
const line = formatLogLine(level, message);
this.buffer.push(line);
// Write to unified session log
writeToSessionLog('IMPORT', level, message);
// Also log to console
if (level === 'ERROR') {
console.error(`[Playlist Logger] ${message}`);
} else {
console.log(`[Playlist Logger] ${message}`);
}
// Flush to file
this.flush();
}
private flush(): void {
try {
fs.appendFileSync(this.logFile, this.buffer.join(''));
this.buffer = [];
} catch (error) {
console.error(`[Playlist Logger] Failed to write to ${this.logFile}:`, error);
}
}
info(message: string): void {
this.write('INFO', message);
}
error(message: string): void {
this.write('ERROR', message);
}
warn(message: string): void {
this.write('WARN', message);
}
debug(message: string): void {
this.write('DEBUG', message);
}
// Alias for info - used for generic logging
log(message: string): void {
this.write('DEBUG', message);
}
// Structured logging methods
logJobStart(playlistName: string, trackCount: number, userId: string): void {
this.info(`Playlist: "${playlistName}" (${trackCount} tracks)`);
this.info(`User: ${userId}`);
this.info('');
}
logTrackMatchingStart(): void {
this.info('--- TRACK MATCHING ---');
}
logTrackMatch(
index: number,
total: number,
title: string,
artist: string,
matched: boolean,
matchedTrackId?: string
): void {
const status = matched ? '✓' : '✗';
const result = matched
? `MATCHED -> ${matchedTrackId}`
: 'NOT FOUND';
this.info(`[${index}/${total}] ${status} "${title}" by ${artist} - ${result}`);
}
logAlbumDownloadStart(count: number): void {
this.info('');
this.info('--- ALBUM DOWNLOADS ---');
this.info(`Requesting ${count} album(s) from Lidarr`);
}
logAlbumQueued(albumName: string, artistName: string, mbid: string, lidarrId?: number): void {
const lidarrInfo = lidarrId ? ` (Lidarr ID: ${lidarrId})` : '';
this.info(`✓ Queued: "${albumName}" by ${artistName} [MBID: ${mbid}]${lidarrInfo}`);
}
logAlbumFailed(albumName: string, artistName: string, reason: string): void {
this.error(`✗ Failed: "${albumName}" by ${artistName} - ${reason}`);
}
logSlskdFallbackStart(albumName: string, artistName: string): void {
this.info('');
this.info('--- SOULSEEK FALLBACK ---');
this.info(`Trying Soulseek for: "${albumName}" by ${artistName}`);
}
logSlskdSearchResult(found: boolean, quality?: string, username?: string, trackCount?: number, sizeMB?: number): void {
if (found) {
this.info(`✓ Soulseek match: ${quality} from ${username} (${trackCount} tracks, ${sizeMB}MB)`);
} else {
this.info(`✗ Soulseek: No suitable results found`);
}
}
logSlskdDownloadQueued(filesQueued: number, username: string): void {
this.info(`✓ Soulseek: Queued ${filesQueued} files from ${username}`);
}
logSlskdDownloadFailed(reason: string): void {
this.error(`✗ Soulseek download failed: ${reason}`);
}
logDownloadProgress(completed: number, failed: number, pending: number): void {
this.info(`Download status: ${completed} completed, ${failed} failed, ${pending} pending`);
}
logPlaylistCreationStart(): void {
this.info('');
this.info('--- PLAYLIST CREATION ---');
}
logPlaylistCreated(playlistId: string, trackCount: number, totalTracks: number): void {
this.info(`Created playlist: ${playlistId}`);
this.info(`Tracks added: ${trackCount}/${totalTracks}`);
}
logJobComplete(tracksMatched: number, tracksTotal: number, playlistId: string | null): void {
this.info('');
this.info('=== JOB COMPLETE ===');
this.info(`Final result: ${tracksMatched}/${tracksTotal} tracks matched`);
if (playlistId) {
this.info(`Playlist ID: ${playlistId}`);
}
this.info(`Log file: ${this.logFile}`);
}
logJobFailed(error: string): void {
this.info('');
this.error('=== JOB FAILED ===');
this.error(error);
this.error(`Log file: ${this.logFile}`);
}
getLogFilePath(): string {
return this.logFile;
}
}
// Factory function to create loggers
export function createPlaylistLogger(jobId: string): PlaylistLogger {
return new PlaylistLogger(jobId);
}
// Quick console+file log for one-off messages
export function logPlaylistEvent(message: string): void {
ensureLogsDir();
const line = formatLogLine('INFO', message);
const eventsFile = path.join(LOGS_DIR, 'events.log');
console.log(`[Playlist] ${message}`);
try {
fs.appendFileSync(eventsFile, line);
} catch (error) {
console.error(`Failed to write to events log:`, error);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { prisma } from "./db";
const SLOW_QUERY_THRESHOLD_MS = 100; // Log queries that take longer than 100ms
/**
* Enable slow query monitoring for Prisma
* Logs queries that exceed the threshold to help identify performance issues
*/
export function enableSlowQueryMonitoring() {
// @ts-ignore - Prisma's query event type is not fully typed
prisma.$on("query", async (e: any) => {
if (e.duration > SLOW_QUERY_THRESHOLD_MS) {
console.warn(
` Slow query detected (${e.duration}ms):\n` +
` Query: ${e.query}\n` +
` Params: ${e.params}`
);
}
});
console.log(
`Slow query monitoring enabled (threshold: ${SLOW_QUERY_THRESHOLD_MS}ms)`
);
}
/**
* Log query statistics for debugging
*/
export async function logQueryStats() {
const stats = await prisma.$metrics.json();
console.log("Database Query Stats:", JSON.stringify(stats, null, 2));
}
+30
View File
@@ -0,0 +1,30 @@
import { createClient } from "redis";
import { config } from "../config";
const redisClient = createClient({ url: config.redisUrl });
// Handle Redis errors gracefully
redisClient.on("error", (err) => {
console.error(" Redis error:", err.message);
// Don't crash the app - Redis is optional for caching
});
redisClient.on("disconnect", () => {
console.log(" Redis disconnected - caching disabled");
});
redisClient.on("reconnecting", () => {
console.log(" Redis reconnecting...");
});
redisClient.on("ready", () => {
console.log("Redis ready");
});
// Connect immediately on module load
redisClient.connect().catch((error) => {
console.error(" Redis connection failed:", error.message);
console.log(" Continuing without Redis caching...");
});
export { redisClient };
+69
View File
@@ -0,0 +1,69 @@
import { prisma } from "./db";
import { encrypt, decrypt, encryptField } from "./encryption";
const CACHE_TTL_MS = 60 * 1000;
let cachedSettings: any | null = null;
let cacheExpiry = 0;
// Re-export encryptField for backwards compatibility
export { encryptField };
export function invalidateSystemSettingsCache() {
cachedSettings = null;
cacheExpiry = 0;
}
/**
* Safely decrypt a field, returning null if decryption fails
* This prevents one corrupted encrypted field from breaking all settings
*/
function safeDecrypt(value: string | null, fieldName?: string): string | null {
if (!value) return null;
try {
return decrypt(value);
} catch (error) {
console.warn(`[Settings] Failed to decrypt ${fieldName || 'field'}, returning null`);
return null;
}
}
export async function getSystemSettings(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && cachedSettings && cacheExpiry > now) {
return { ...cachedSettings };
}
const settings = await prisma.systemSettings.findUnique({
where: { id: "default" },
});
if (!settings) {
cachedSettings = null;
cacheExpiry = 0;
return null;
}
// Decrypt sensitive fields - use safeDecrypt to handle corrupted fields gracefully
const decrypted = {
...settings,
mullvadPrivateKey: safeDecrypt(settings.mullvadPrivateKey, 'mullvadPrivateKey'),
nordvpnPassword: safeDecrypt(settings.nordvpnPassword, 'nordvpnPassword'),
protonvpnPassword: safeDecrypt(settings.protonvpnPassword, 'protonvpnPassword'),
openvpnConfig: safeDecrypt(settings.openvpnConfig, 'openvpnConfig'),
openvpnPassword: safeDecrypt(settings.openvpnPassword, 'openvpnPassword'),
lidarrApiKey: safeDecrypt(settings.lidarrApiKey, 'lidarrApiKey'),
nzbgetPassword: safeDecrypt(settings.nzbgetPassword, 'nzbgetPassword'),
qbittorrentPassword: safeDecrypt(settings.qbittorrentPassword, 'qbittorrentPassword'),
openaiApiKey: safeDecrypt(settings.openaiApiKey, 'openaiApiKey'),
lastfmApiKey: safeDecrypt(settings.lastfmApiKey, 'lastfmApiKey'),
lastfmApiSecret: safeDecrypt(settings.lastfmApiSecret, 'lastfmApiSecret'),
fanartApiKey: safeDecrypt(settings.fanartApiKey, 'fanartApiKey'),
audiobookshelfApiKey: safeDecrypt(settings.audiobookshelfApiKey, 'audiobookshelfApiKey'),
soulseekPassword: safeDecrypt(settings.soulseekPassword, 'soulseekPassword'),
};
cachedSettings = decrypted;
cacheExpiry = now + CACHE_TTL_MS;
return { ...decrypted };
}
+371
View File
@@ -0,0 +1,371 @@
import { Artist } from "@prisma/client";
import { prisma } from "../utils/db";
import { wikidataService } from "../services/wikidata";
import { lastFmService } from "../services/lastfm";
import { fanartService } from "../services/fanart";
import { deezerService } from "../services/deezer";
import { musicBrainzService } from "../services/musicbrainz";
import { normalizeArtistName } from "../utils/artistNormalization";
import { coverArtService } from "../services/coverArt";
import { redisClient } from "../utils/redis";
/**
* Enriches an artist with metadata from Wikidata and Last.fm
* - Fetches artist bio/summary and hero image from Wikidata
* - Falls back to Last.fm if Wikidata fails
* - Fetches similar artists from Last.fm
*/
export async function enrichSimilarArtist(artist: Artist): Promise<void> {
const logPrefix = `[ENRICH ${artist.name}]`;
console.log(`${logPrefix} Starting enrichment (MBID: ${artist.mbid})`);
// Mark as enriching
await prisma.artist.update({
where: { id: artist.id },
data: { enrichmentStatus: "enriching" },
});
// Track which source provided data
let imageSource = "none";
let summarySource = "none";
try {
// If artist has a temp MBID, try to get the real one from MusicBrainz
if (artist.mbid.startsWith("temp-")) {
console.log(`${logPrefix} Temp MBID detected, searching MusicBrainz...`);
try {
const mbResults = await musicBrainzService.searchArtist(
artist.name,
1
);
if (mbResults.length > 0 && mbResults[0].id) {
const realMbid = mbResults[0].id;
console.log(`${logPrefix} MusicBrainz: Found real MBID: ${realMbid}`);
// Update artist with real MBID
await prisma.artist.update({
where: { id: artist.id },
data: { mbid: realMbid },
});
// Update the local artist object
artist.mbid = realMbid;
} else {
console.log(`${logPrefix} MusicBrainz: No match found, keeping temp MBID`);
}
} catch (error: any) {
console.log(`${logPrefix} MusicBrainz: FAILED - ${error?.message || error}`);
}
}
// Try Wikidata first (only if we have a real MBID)
let summary = null;
let heroUrl = null;
if (!artist.mbid.startsWith("temp-")) {
console.log(`${logPrefix} Wikidata: Fetching for MBID ${artist.mbid}...`);
try {
const wikidataInfo = await wikidataService.getArtistInfo(
artist.mbid
);
if (wikidataInfo) {
summary = wikidataInfo.summary;
heroUrl = wikidataInfo.image;
if (summary) summarySource = "wikidata";
if (heroUrl) imageSource = "wikidata";
console.log(`${logPrefix} Wikidata: SUCCESS (image: ${heroUrl ? "yes" : "no"}, summary: ${summary ? "yes" : "no"})`);
} else {
console.log(`${logPrefix} Wikidata: No data returned`);
}
} catch (error: any) {
console.log(`${logPrefix} Wikidata: FAILED - ${error?.message || error}`);
}
} else {
console.log(`${logPrefix} Wikidata: Skipped (temp MBID)`);
}
// Fallback to Last.fm if Wikidata didn't work
if (!summary || !heroUrl) {
console.log(`${logPrefix} Last.fm: Fetching (need summary: ${!summary}, need image: ${!heroUrl})...`);
try {
const validMbid = artist.mbid.startsWith("temp-")
? undefined
: artist.mbid;
const lastfmInfo = await lastFmService.getArtistInfo(
artist.name,
validMbid
);
if (lastfmInfo) {
// Extract text from bio object (bio.summary or bio.content)
if (!summary && lastfmInfo.bio) {
const bio = lastfmInfo.bio as any;
summary = bio.summary || bio.content || null;
if (summary) {
summarySource = "lastfm";
console.log(`${logPrefix} Last.fm: Got summary`);
}
}
// Try Fanart.tv for image (only with real MBID)
if (!heroUrl && !artist.mbid.startsWith("temp-")) {
console.log(`${logPrefix} Fanart.tv: Fetching for MBID ${artist.mbid}...`);
try {
heroUrl = await fanartService.getArtistImage(
artist.mbid
);
if (heroUrl) {
imageSource = "fanart.tv";
console.log(`${logPrefix} Fanart.tv: SUCCESS - ${heroUrl.substring(0, 60)}...`);
} else {
console.log(`${logPrefix} Fanart.tv: No image found`);
}
} catch (error: any) {
console.log(`${logPrefix} Fanart.tv: FAILED - ${error?.message || error}`);
}
}
// Fallback to Deezer
if (!heroUrl) {
console.log(`${logPrefix} Deezer: Fetching for "${artist.name}"...`);
try {
heroUrl = await deezerService.getArtistImage(
artist.name
);
if (heroUrl) {
imageSource = "deezer";
console.log(`${logPrefix} Deezer: SUCCESS - ${heroUrl.substring(0, 60)}...`);
} else {
console.log(`${logPrefix} Deezer: No image found`);
}
} catch (error: any) {
console.log(`${logPrefix} Deezer: FAILED - ${error?.message || error}`);
}
}
// Last fallback to Last.fm's own image
if (!heroUrl && lastfmInfo.image) {
const imageArray = lastfmInfo.image as any[];
if (Array.isArray(imageArray)) {
const bestImage =
imageArray.find(
(img) => img.size === "extralarge"
)?.["#text"] ||
imageArray.find(
(img) => img.size === "large"
)?.["#text"] ||
imageArray.find(
(img) => img.size === "medium"
)?.["#text"];
// Filter out Last.fm's placeholder images
if (
bestImage &&
!bestImage.includes(
"2a96cbd8b46e442fc41c2b86b821562f"
)
) {
heroUrl = bestImage;
imageSource = "lastfm";
console.log(`${logPrefix} Last.fm image: SUCCESS`);
} else {
console.log(`${logPrefix} Last.fm image: Placeholder/none`);
}
}
}
} else {
console.log(`${logPrefix} Last.fm: No data returned`);
}
} catch (error: any) {
console.log(`${logPrefix} Last.fm: FAILED - ${error?.message || error}`);
}
}
// Get similar artists from Last.fm
let similarArtists: Array<{
name: string;
mbid: string | null;
similarity: number;
}> = [];
try {
// Filter out temp MBIDs
const validMbid = artist.mbid.startsWith("temp-")
? ""
: artist.mbid;
similarArtists = await lastFmService.getSimilarArtists(
validMbid,
artist.name
);
console.log(`${logPrefix} Similar artists: Found ${similarArtists.length}`);
} catch (error: any) {
console.log(`${logPrefix} Similar artists: FAILED - ${error?.message || error}`);
}
// Log enrichment summary
console.log(`${logPrefix} SUMMARY: image=${imageSource}, summary=${summarySource}, heroUrl=${heroUrl ? "set" : "null"}`);
// Update artist with enriched data
await prisma.artist.update({
where: { id: artist.id },
data: {
summary,
heroUrl,
lastEnriched: new Date(),
enrichmentStatus: "completed",
},
});
// Store similar artists
if (similarArtists.length > 0) {
// Delete existing similar artist relationships
await prisma.similarArtist.deleteMany({
where: { fromArtistId: artist.id },
});
// Create new relationships
for (const similar of similarArtists) {
// Find existing similar artist (don't create new ones)
let similarArtistRecord = null;
if (similar.mbid) {
// Try to find by MBID first
similarArtistRecord = await prisma.artist.findUnique({
where: { mbid: similar.mbid },
});
}
if (!similarArtistRecord) {
// Try to find by normalized name (case-insensitive)
const normalizedSimilarName = normalizeArtistName(
similar.name
);
similarArtistRecord = await prisma.artist.findFirst({
where: { normalizedName: normalizedSimilarName },
});
}
// Only create similarity relationship if the similar artist already exists in our database
// This prevents endless crawling of similar artists
if (similarArtistRecord) {
await prisma.similarArtist.upsert({
where: {
fromArtistId_toArtistId: {
fromArtistId: artist.id,
toArtistId: similarArtistRecord.id,
},
},
create: {
fromArtistId: artist.id,
toArtistId: similarArtistRecord.id,
weight: similar.similarity,
},
update: {
weight: similar.similarity,
},
});
}
}
console.log(`${logPrefix} Stored ${similarArtists.length} similar artist relationships`);
}
// ========== ALBUM COVER ENRICHMENT ==========
// Fetch covers for all albums belonging to this artist that don't have covers yet
await enrichAlbumCovers(artist.id, heroUrl);
// Cache artist image in Redis for faster access
if (heroUrl) {
try {
await redisClient.setEx(`hero:${artist.id}`, 7 * 24 * 60 * 60, heroUrl);
} catch (err) {
// Redis errors are non-critical
}
}
} catch (error: any) {
console.error(`${logPrefix} ENRICHMENT FAILED:`, error?.message || error);
// Mark as failed
await prisma.artist.update({
where: { id: artist.id },
data: { enrichmentStatus: "failed" },
});
throw error;
}
}
/**
* Enrich album covers for an artist
* Fetches covers from Cover Art Archive for albums without covers
*/
async function enrichAlbumCovers(artistId: string, artistHeroUrl: string | null): Promise<void> {
try {
// Find albums for this artist that don't have cover art
const albumsWithoutCovers = await prisma.album.findMany({
where: {
artistId,
OR: [
{ coverUrl: null },
{ coverUrl: "" },
],
},
select: {
id: true,
rgMbid: true,
title: true,
},
});
if (albumsWithoutCovers.length === 0) {
console.log(` All albums already have covers`);
return;
}
console.log(` Fetching covers for ${albumsWithoutCovers.length} albums...`);
let fetchedCount = 0;
const BATCH_SIZE = 3; // Limit concurrent requests
// Process in batches to avoid overwhelming Cover Art Archive
for (let i = 0; i < albumsWithoutCovers.length; i += BATCH_SIZE) {
const batch = albumsWithoutCovers.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async (album) => {
if (!album.rgMbid) return;
try {
const coverUrl = await coverArtService.getCoverArt(album.rgMbid);
if (coverUrl) {
// Save to database
await prisma.album.update({
where: { id: album.id },
data: { coverUrl },
});
// Cache in Redis
try {
await redisClient.setEx(
`album-cover:${album.id}`,
30 * 24 * 60 * 60, // 30 days
coverUrl
);
} catch (err) {
// Redis errors are non-critical
}
fetchedCount++;
}
} catch (err) {
// Cover art fetch failed, continue with next album
console.log(` No cover found for: ${album.title}`);
}
})
);
}
console.log(` Fetched ${fetchedCount}/${albumsWithoutCovers.length} album covers`);
} catch (error) {
console.error(` Failed to enrich album covers:`, error);
// Don't throw - album cover failures shouldn't fail the entire enrichment
}
}
+164
View File
@@ -0,0 +1,164 @@
import { prisma } from "../utils/db";
import fs from "fs/promises";
import path from "path";
export async function cleanupDiscoveryTracks() {
console.log("\nCleaning up old discovery tracks...");
try {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Find discovery albums older than 7 days
const oldDiscoveryAlbums = await prisma.discoveryAlbum.findMany({
where: {
downloadedAt: { lt: sevenDaysAgo },
},
include: {
tracks: true,
},
});
console.log(
` Found ${oldDiscoveryAlbums.length} old discovery albums`
);
if (oldDiscoveryAlbums.length === 0) {
console.log(" No cleanup needed");
return { deletedAlbums: 0, deletedTracks: 0 };
}
let deletedAlbums = 0;
let deletedTracks = 0;
for (const album of oldDiscoveryAlbums) {
// Check if any tracks should be kept
const tracksToKeep = [];
const tracksToDelete = [];
for (const track of album.tracks) {
let shouldKeep = false;
// Keep if user marked it
if (track.userKept) {
shouldKeep = true;
}
// Keep if in any playlist (check playlist_items)
if (track.trackId) {
const inPlaylist = await prisma.playlistItem.findFirst({
where: { trackId: track.trackId },
});
if (inPlaylist) {
shouldKeep = true;
}
}
// Keep if user liked it
if (track.trackId) {
const liked = await prisma.likedTrack.findFirst({
where: { trackId: track.trackId },
});
if (liked) {
shouldKeep = true;
}
}
// Keep if played in last 7 days
if (track.lastPlayedAt) {
const lastPlayed = new Date(track.lastPlayedAt);
if (lastPlayed >= sevenDaysAgo) {
shouldKeep = true;
}
}
if (shouldKeep) {
tracksToKeep.push(track);
} else {
tracksToDelete.push(track);
}
}
// If all tracks should be deleted, delete the album
if (tracksToDelete.length === album.tracks.length) {
console.log(
` Deleting album: ${album.albumTitle} by ${album.artistName}`
);
// Delete physical files
if (album.folderPath) {
try {
await fs.rm(album.folderPath, {
recursive: true,
force: true,
});
console.log(` Deleted folder: ${album.folderPath}`);
} catch (err) {
console.warn(` Could not delete folder: ${err}`);
}
}
// Delete from database (cascade deletes tracks)
await prisma.discoveryAlbum.delete({
where: { id: album.id },
});
deletedAlbums++;
deletedTracks += album.tracks.length;
} else if (tracksToDelete.length > 0) {
// Delete specific tracks only
console.log(
` Partial cleanup: ${album.albumTitle} (${tracksToDelete.length}/${album.tracks.length} tracks)`
);
for (const track of tracksToDelete) {
// Delete physical file
if (track.filePath) {
try {
await fs.unlink(track.filePath);
console.log(` Deleted: ${track.fileName}`);
} catch (err) {
console.warn(` Could not delete file: ${err}`);
}
}
// Delete from database
await prisma.discoveryTrack.delete({
where: { id: track.id },
});
deletedTracks++;
}
} else {
console.log(
` Keeping album: ${album.albumTitle} (all tracks in use)`
);
}
}
console.log(
`\n Cleanup complete: ${deletedAlbums} albums, ${deletedTracks} tracks deleted`
);
return { deletedAlbums, deletedTracks };
} catch (error) {
console.error("Cleanup discovery tracks error:", error);
throw error;
}
}
// CLI entry point
if (require.main === module) {
cleanupDiscoveryTracks()
.then((result) => {
console.log("\nDiscovery cleanup completed successfully");
console.log(
`Deleted: ${result.deletedAlbums} albums, ${result.deletedTracks} tracks`
);
process.exit(0);
})
.catch((err) => {
console.error("\n Failed:", err);
process.exit(1);
});
}
+384
View File
@@ -0,0 +1,384 @@
/**
* Data Integrity Worker
*
* Periodic cleanup to maintain database health:
* 1. Remove expired DiscoverExclusion records
* 2. Clean up orphaned DiscoveryTrack records
* 3. Clean up orphaned Album records (DISCOVER location with no DiscoveryAlbum)
* 4. Consolidate duplicate artists (temp MBID vs real MBID)
* 5. Clean up orphaned artists (no albums)
* 6. Clean up old completed/failed DownloadJob records
*/
import { prisma } from "../utils/db";
interface IntegrityReport {
expiredExclusions: number;
orphanedDiscoveryTracks: number;
mislocatedAlbums: number;
orphanedAlbums: number;
consolidatedArtists: number;
orphanedArtists: number;
oldDownloadJobs: number;
}
export async function runDataIntegrityCheck(): Promise<IntegrityReport> {
console.log("\nRunning data integrity check...");
const report: IntegrityReport = {
expiredExclusions: 0,
orphanedDiscoveryTracks: 0,
mislocatedAlbums: 0,
orphanedAlbums: 0,
consolidatedArtists: 0,
orphanedArtists: 0,
oldDownloadJobs: 0,
};
// 1. Remove expired DiscoverExclusion records
const expiredExclusions = await prisma.discoverExclusion.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
report.expiredExclusions = expiredExclusions.count;
if (expiredExclusions.count > 0) {
console.log(
` Removed ${expiredExclusions.count} expired exclusions`
);
}
// 2. Clean up orphaned DiscoveryTrack records (tracks whose Track record was deleted)
const orphanedDiscoveryTracks = await prisma.discoveryTrack.deleteMany({
where: {
trackId: null,
},
});
report.orphanedDiscoveryTracks = orphanedDiscoveryTracks.count;
if (orphanedDiscoveryTracks.count > 0) {
console.log(
` Removed ${orphanedDiscoveryTracks.count} orphaned discovery track records`
);
}
// 3. Clean up orphaned DISCOVER albums (no active DiscoveryAlbum record AND no OwnedAlbum)
const discoverAlbums = await prisma.album.findMany({
where: { location: "DISCOVER" },
include: { artist: true },
});
for (const album of discoverAlbums) {
// Check if there's an ACTIVE, LIKED, or MOVED DiscoveryAlbum record
const hasActiveRecord = await prisma.discoveryAlbum.findFirst({
where: {
OR: [
{ rgMbid: album.rgMbid },
{
albumTitle: { equals: album.title, mode: "insensitive" },
artistName: { equals: album.artist.name, mode: "insensitive" },
},
],
status: { in: ["ACTIVE", "LIKED", "MOVED"] },
},
});
// Also check if there's an OwnedAlbum record (user liked it)
const hasOwnedRecord = await prisma.ownedAlbum.findFirst({
where: {
artistId: album.artistId,
rgMbid: album.rgMbid,
},
});
if (!hasActiveRecord && !hasOwnedRecord) {
// Delete tracks first
await prisma.track.deleteMany({
where: { albumId: album.id },
});
// Delete album
await prisma.album.delete({
where: { id: album.id },
});
report.orphanedAlbums++;
console.log(
` Removed orphaned album: ${album.artist.name} - ${album.title}`
);
}
}
// 4. Fix mislocated LIBRARY albums that should be DISCOVER
// This happens when:
// - Discovery tracks have featured artists that don't match the download job
// - Lidarr downloads a different album than requested (e.g., "Broods" album vs "Evergreen" album)
// - Album title metadata differs from the requested album
// - Scanner ran before DiscoveryAlbum records were created
const discoveryJobs = await prisma.downloadJob.findMany({
where: {
discoveryBatchId: { not: null },
status: { in: ["pending", "processing", "completed"] },
},
});
// Build sets of discovery album titles AND artist names (normalized)
const discoveryAlbumTitles = new Set<string>();
const discoveryArtistNames = new Set<string>();
const discoveryArtistMbids = new Set<string>();
for (const job of discoveryJobs) {
const metadata = job.metadata as any;
const albumTitle = (metadata?.albumTitle || "").toLowerCase().trim();
const artistName = (metadata?.artistName || "").toLowerCase().trim();
const artistMbid = metadata?.artistMbid;
if (albumTitle) discoveryAlbumTitles.add(albumTitle);
if (artistName) discoveryArtistNames.add(artistName);
if (artistMbid) discoveryArtistMbids.add(artistMbid);
}
// Also check DiscoveryAlbum table for ALL discoveries (not just active)
// This catches albums where Lidarr downloaded a different album than requested
const allDiscoveryAlbums = await prisma.discoveryAlbum.findMany();
for (const da of allDiscoveryAlbums) {
discoveryAlbumTitles.add(da.albumTitle.toLowerCase().trim());
discoveryArtistNames.add(da.artistName.toLowerCase().trim());
if (da.artistMbid) discoveryArtistMbids.add(da.artistMbid);
}
// Find LIBRARY albums that might be discovery
const libraryAlbums = await prisma.album.findMany({
where: { location: "LIBRARY" },
include: { artist: true },
});
let mislocatedAlbumsFixed = 0;
for (const album of libraryAlbums) {
const normalizedTitle = album.title.toLowerCase().trim();
const normalizedArtist = album.artist.name.toLowerCase().trim();
// Match criteria:
// 1. Album title matches a discovery download, OR
// 2. Artist name matches a discovery download (catches Lidarr downloading wrong album), OR
// 3. Artist MBID matches a discovery download
const albumMatches = discoveryAlbumTitles.has(normalizedTitle);
const artistNameMatches = discoveryArtistNames.has(normalizedArtist);
const artistMbidMatches = album.artist.mbid ? discoveryArtistMbids.has(album.artist.mbid) : false;
if (!albumMatches && !artistNameMatches && !artistMbidMatches) continue;
// KEY FIX: Check if artist has ANY protected OwnedAlbum records:
// - native_scan = real user library from before discovery
// - discovery_liked = user liked a discovery album (should be kept!)
const hasProtectedOwnedAlbum = await prisma.ownedAlbum.findFirst({
where: {
artistId: album.artistId,
source: { in: ["native_scan", "discovery_liked"] },
},
});
if (hasProtectedOwnedAlbum) {
// Artist has protected content - this album should stay as LIBRARY
continue;
}
// Also check if artist has any LIKED discovery albums (double-check)
const hasLikedDiscovery = await prisma.discoveryAlbum.findFirst({
where: {
artistMbid: album.artist.mbid || undefined,
status: { in: ["LIKED", "MOVED"] },
},
});
if (hasLikedDiscovery) {
// User liked albums from this artist - don't touch
continue;
}
const reason = albumMatches
? `album title "${album.title}" matches discovery`
: artistNameMatches
? `artist "${album.artist.name}" matches discovery`
: `artist MBID matches discovery`;
console.log(
` Fixing mislocated album: ${album.artist.name} - ${album.title} (LIBRARY -> DISCOVER, ${reason})`
);
// Update album location
await prisma.album.update({
where: { id: album.id },
data: { location: "DISCOVER" },
});
// Remove OwnedAlbum record (but only non-native ones)
await prisma.ownedAlbum.deleteMany({
where: {
rgMbid: album.rgMbid,
source: { not: "native_scan" },
},
});
mislocatedAlbumsFixed++;
}
report.mislocatedAlbums = mislocatedAlbumsFixed;
if (mislocatedAlbumsFixed > 0) {
console.log(` Fixed ${mislocatedAlbumsFixed} mislocated albums`);
}
// 5. Clean up albums with NO tracks (files were deleted from filesystem)
// These are "ghost" albums that still appear in the database but have no actual content
const emptyAlbums = await prisma.album.findMany({
where: {
tracks: { none: {} },
},
include: { artist: true },
});
for (const album of emptyAlbums) {
// Delete the album record
await prisma.album.delete({
where: { id: album.id },
});
// Also delete any associated OwnedAlbum records
await prisma.ownedAlbum.deleteMany({
where: { rgMbid: album.rgMbid },
});
report.orphanedAlbums++;
console.log(
` Removed empty album (no tracks): ${album.artist.name} - ${album.title}`
);
}
// 6. Clean up orphaned OwnedAlbum records (no matching Album record)
// This happens when files are deleted but Lidarr records remain
const orphanedOwnedAlbums = await prisma.$executeRaw`
DELETE FROM "OwnedAlbum" oa
WHERE NOT EXISTS (
SELECT 1 FROM "Album" a WHERE a."rgMbid" = oa."rgMbid"
)
`;
if (orphanedOwnedAlbums > 0) {
console.log(
` Removed ${orphanedOwnedAlbums} orphaned OwnedAlbum records`
);
}
// 7. Consolidate duplicate artists (same name, one with temp MBID, one with real)
const tempArtists = await prisma.artist.findMany({
where: {
mbid: { startsWith: "temp-" },
},
include: { albums: true },
});
for (const tempArtist of tempArtists) {
// Find a real artist with the same normalized name
const realArtist = await prisma.artist.findFirst({
where: {
normalizedName: tempArtist.normalizedName,
mbid: { not: { startsWith: "temp-" } },
},
});
if (realArtist) {
// Move all albums from temp artist to real artist
await prisma.album.updateMany({
where: { artistId: tempArtist.id },
data: { artistId: realArtist.id },
});
// Delete SimilarArtist relations
await prisma.similarArtist.deleteMany({
where: {
OR: [
{ fromArtistId: tempArtist.id },
{ toArtistId: tempArtist.id },
],
},
});
// Delete temp artist
await prisma.artist.delete({
where: { id: tempArtist.id },
});
report.consolidatedArtists++;
console.log(
` Consolidated "${tempArtist.name}" (temp) into real artist`
);
}
}
// 8. Clean up orphaned artists (no albums)
const orphanedArtists = await prisma.artist.findMany({
where: {
albums: { none: {} },
},
});
if (orphanedArtists.length > 0) {
// Delete SimilarArtist relations first
await prisma.similarArtist.deleteMany({
where: {
OR: [
{ fromArtistId: { in: orphanedArtists.map((a) => a.id) } },
{
toArtistId: {
in: orphanedArtists.map((a) => a.id),
},
},
],
},
});
// Delete orphaned artists
await prisma.artist.deleteMany({
where: { id: { in: orphanedArtists.map((a) => a.id) } },
});
report.orphanedArtists = orphanedArtists.length;
}
// 9. Clean up old DownloadJob records (older than 30 days, completed/failed)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const oldJobs = await prisma.downloadJob.deleteMany({
where: {
status: { in: ["completed", "failed"] },
completedAt: { lt: thirtyDaysAgo },
},
});
report.oldDownloadJobs = oldJobs.count;
if (oldJobs.count > 0) {
console.log(` Removed ${oldJobs.count} old download jobs`);
}
// Summary
console.log("\nData integrity check complete:");
console.log(` - Expired exclusions: ${report.expiredExclusions}`);
console.log(
` - Orphaned discovery tracks: ${report.orphanedDiscoveryTracks}`
);
console.log(` - Mislocated albums (LIBRARY->DISCOVER): ${report.mislocatedAlbums}`);
console.log(` - Orphaned albums: ${report.orphanedAlbums}`);
console.log(` - Consolidated artists: ${report.consolidatedArtists}`);
console.log(` - Orphaned artists: ${report.orphanedArtists}`);
console.log(` - Old download jobs: ${report.oldDownloadJobs}`);
return report;
}
// CLI entry point
if (require.main === module) {
runDataIntegrityCheck()
.then((report) => {
console.log("\nData integrity check completed successfully");
process.exit(0);
})
.catch((err) => {
console.error("\n Data integrity check failed:", err);
process.exit(1);
});
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Discover Weekly Cron Scheduler
*
* Automatically generates Discover Weekly playlists on Sunday evenings
* so users have fresh music waiting Monday morning.
*/
import cron, { ScheduledTask } from "node-cron";
import { prisma } from "../utils/db";
import { discoverQueue } from "./queues";
let cronTask: ScheduledTask | null = null;
export function startDiscoverWeeklyCron() {
// Run every Sunday at 8 PM (20:00)
// Cron format: minute hour day-of-month month day-of-week
// "0 20 * * 0" = At 20:00 on Sunday
const schedule = "0 20 * * 0";
console.log(
`Scheduling Discover Weekly to run: ${schedule} (Sundays at 8 PM)`
);
cronTask = cron.schedule(schedule, async () => {
console.log(`\n === Discover Weekly Cron Triggered ===`);
console.log(` Time: ${new Date().toLocaleString()}`);
try {
// Get all users with Discover Weekly enabled
const configs = await prisma.userDiscoverConfig.findMany({
where: {
enabled: true,
},
select: {
userId: true,
playlistSize: true,
},
});
console.log(
` Found ${configs.length} users with Discover Weekly enabled`
);
for (const config of configs) {
console.log(` Queueing job for user ${config.userId}...`);
await discoverQueue.add("discover-weekly", {
userId: config.userId,
});
}
console.log(` Queued ${configs.length} Discover Weekly jobs`);
} catch (error: any) {
console.error(` ✗ Discover Weekly cron error:`, error.message);
}
});
console.log("Discover Weekly cron scheduler started");
}
export function stopDiscoverWeeklyCron() {
if (cronTask) {
cronTask.stop();
cronTask = null;
console.log("Discover Weekly cron scheduler stopped");
}
}
+138
View File
@@ -0,0 +1,138 @@
import { prisma } from "../utils/db";
import { enrichSimilarArtist } from "./artistEnrichment";
let isEnriching = false;
let enrichmentInterval: NodeJS.Timeout | null = null;
// Configuration for enrichment worker
const ENRICHMENT_BATCH_SIZE = 10; // Process 10 artists at a time (reduced from 50)
const ENRICHMENT_INTERVAL_MS = 30 * 1000; // Run every 30 seconds (increased from 5s)
/**
* Background worker that continuously enriches pending artists
* Throttled to reduce API load and prevent rate limiting
*/
export async function startEnrichmentWorker() {
console.log("Starting enrichment worker...");
console.log(` - Concurrent artists: ${ENRICHMENT_BATCH_SIZE}`);
console.log(` - Check interval: ${ENRICHMENT_INTERVAL_MS / 1000} seconds`);
// Run immediately on start
await enrichNextBatch();
// Then run at configured interval
enrichmentInterval = setInterval(async () => {
await enrichNextBatch();
}, ENRICHMENT_INTERVAL_MS);
}
/**
* Stop the enrichment worker
*/
export function stopEnrichmentWorker() {
if (enrichmentInterval) {
clearInterval(enrichmentInterval);
enrichmentInterval = null;
console.log(" Enrichment worker stopped");
}
}
/**
* Process the next batch of pending artists (throttled)
*/
async function enrichNextBatch() {
// Skip if already enriching
if (isEnriching) {
return;
}
try {
isEnriching = true;
// Find the next batch of pending or failed artists that have ANY albums
// (owned OR discovery)
const artists = await prisma.artist.findMany({
where: {
OR: [
{ enrichmentStatus: "pending" },
{ enrichmentStatus: "failed" },
],
// Enrich artists that have any albums in the database
albums: {
some: {},
},
},
orderBy: { name: "asc" },
take: ENRICHMENT_BATCH_SIZE,
});
if (artists.length === 0) {
// No more artists to enrich
return;
}
console.log(
`\n[Enrichment Worker] Processing batch of ${artists.length} artists...`
);
// Enrich all artists concurrently
await Promise.allSettled(
artists.map(async (artist) => {
try {
console.log(` → Starting: ${artist.name}`);
await enrichSimilarArtist(artist);
console.log(` Completed: ${artist.name}`);
} catch (error) {
console.error(` Failed: ${artist.name}`, error);
}
})
);
// Log progress
const progress = await getEnrichmentProgress();
console.log(
`\n[Enrichment Progress] ${progress.completed}/${progress.total} (${progress.progress}%)`
);
console.log(
` Pending: ${progress.pending} | Failed: ${progress.failed}\n`
);
} catch (error) {
console.error(` [Enrichment Worker] Batch error:`, error);
} finally {
isEnriching = false;
}
}
/**
* Get enrichment progress statistics
*/
export async function getEnrichmentProgress() {
const statusCounts = await prisma.artist.groupBy({
by: ["enrichmentStatus"],
_count: true,
});
const total = statusCounts.reduce((sum, s) => sum + s._count, 0);
const completed =
statusCounts.find((s) => s.enrichmentStatus === "completed")?._count ||
0;
const failed =
statusCounts.find((s) => s.enrichmentStatus === "failed")?._count || 0;
const enriching =
statusCounts.find((s) => s.enrichmentStatus === "enriching")?._count ||
0;
const pending =
statusCounts.find((s) => s.enrichmentStatus === "pending")?._count || 0;
const progress = total > 0 ? ((completed + failed) / total) * 100 : 0;
return {
total,
completed,
failed,
enriching,
pending,
progress: Math.round(progress * 10) / 10,
isComplete: pending === 0 && enriching === 0,
};
}
+268
View File
@@ -0,0 +1,268 @@
import {
scanQueue,
discoverQueue,
imageQueue,
validationQueue,
} from "./queues";
import { processScan } from "./processors/scanProcessor";
import { processDiscoverWeekly } from "./processors/discoverProcessor";
import { processImageOptimization } from "./processors/imageProcessor";
import { processValidation } from "./processors/validationProcessor";
import { startUnifiedEnrichmentWorker, stopUnifiedEnrichmentWorker } from "./unifiedEnrichment";
import { downloadQueueManager } from "../services/downloadQueue";
import { prisma } from "../utils/db";
import { startDiscoverWeeklyCron, stopDiscoverWeeklyCron } from "./discoverCron";
import { runDataIntegrityCheck } from "./dataIntegrity";
import { simpleDownloadManager } from "../services/simpleDownloadManager";
// Track intervals and timeouts for cleanup
const intervals: NodeJS.Timeout[] = [];
const timeouts: NodeJS.Timeout[] = [];
// Register processors with named job types
scanQueue.process("scan", processScan);
discoverQueue.process(processDiscoverWeekly);
imageQueue.process(processImageOptimization);
validationQueue.process(processValidation);
// Register download queue callback for unavailable albums
downloadQueueManager.onUnavailableAlbum(async (info) => {
console.log(
` Recording unavailable album: ${info.artistName} - ${info.albumTitle}`
);
if (!info.userId) {
console.log(` No userId provided, skipping database record`);
return;
}
try {
// Get week start date from discovery album if it exists
const discoveryAlbum = await prisma.discoveryAlbum.findFirst({
where: { rgMbid: info.albumMbid },
orderBy: { downloadedAt: "desc" },
});
await prisma.unavailableAlbum.create({
data: {
userId: info.userId,
artistName: info.artistName,
albumTitle: info.albumTitle,
albumMbid: info.albumMbid,
artistMbid: info.artistMbid,
similarity: info.similarity || 0,
tier: info.tier || "unknown",
weekStartDate: discoveryAlbum?.weekStartDate || new Date(),
attemptNumber: 0,
},
});
console.log(` Recorded in database`);
} catch (error: any) {
// Handle duplicate entries (album already marked as unavailable)
if (error.code === "P2002") {
console.log(` Album already marked as unavailable`);
} else {
console.error(
` Failed to record unavailable album:`,
error.message
);
}
}
});
// Start unified enrichment worker
// Handles: artist metadata, track tags (Last.fm), audio analysis queueing (Essentia)
startUnifiedEnrichmentWorker().catch((err) => {
console.error("Failed to start unified enrichment worker:", err);
});
// Event handlers for scan queue
scanQueue.on("completed", (job, result) => {
console.log(
`Scan job ${job.id} completed: +${result.tracksAdded} ~${result.tracksUpdated} -${result.tracksRemoved}`
);
});
scanQueue.on("failed", (job, err) => {
console.error(`✗ Scan job ${job.id} failed:`, err.message);
});
scanQueue.on("active", (job) => {
console.log(` Scan job ${job.id} started`);
});
// Event handlers for discover queue
discoverQueue.on("completed", (job, result) => {
if (result.success) {
console.log(
`Discover job ${job.id} completed: ${result.playlistName} (${result.songCount} songs)`
);
} else {
console.log(`✗ Discover job ${job.id} failed: ${result.error}`);
}
});
discoverQueue.on("failed", (job, err) => {
console.error(`✗ Discover job ${job.id} failed:`, err.message);
});
discoverQueue.on("active", (job) => {
console.log(` Discover job ${job.id} started for user ${job.data.userId}`);
});
// Event handlers for image queue
imageQueue.on("completed", (job, result) => {
console.log(
`Image job ${job.id} completed: ${
result.success ? "success" : result.error
}`
);
});
imageQueue.on("failed", (job, err) => {
console.error(`✗ Image job ${job.id} failed:`, err.message);
});
// Event handlers for validation queue
validationQueue.on("completed", (job, result) => {
console.log(
`Validation job ${job.id} completed: ${result.tracksChecked} checked, ${result.tracksRemoved} removed`
);
});
validationQueue.on("failed", (job, err) => {
console.error(`✗ Validation job ${job.id} failed:`, err.message);
});
validationQueue.on("active", (job) => {
console.log(` Validation job ${job.id} started`);
});
console.log("Worker processors registered and event handlers attached");
// Start Discovery Weekly cron scheduler (Sundays at 8 PM)
startDiscoverWeeklyCron();
// Run data integrity check on startup and then every 24 hours
timeouts.push(
setTimeout(() => {
runDataIntegrityCheck().catch((err) => {
console.error("Data integrity check failed:", err);
});
}, 10000) // Run 10 seconds after startup
);
intervals.push(
setInterval(() => {
runDataIntegrityCheck().catch((err) => {
console.error("Data integrity check failed:", err);
});
}, 24 * 60 * 60 * 1000) // Run every 24 hours
);
console.log("Data integrity check scheduled (every 24 hours)");
// Run stale download cleanup every 2 minutes
// This catches downloads that timed out even if the queue cleaner isn't running
intervals.push(
setInterval(async () => {
try {
const staleCount = await simpleDownloadManager.markStaleJobsAsFailed();
if (staleCount > 0) {
console.log(
`⏰ Periodic cleanup: marked ${staleCount} stale download(s) as failed`
);
}
} catch (err) {
console.error("Stale download cleanup failed:", err);
}
}, 2 * 60 * 1000) // Every 2 minutes
);
console.log("Stale download cleanup scheduled (every 2 minutes)");
// Run Lidarr queue cleanup every 5 minutes
// This catches stuck/failed imports even if webhooks fail
intervals.push(
setInterval(async () => {
try {
const result = await simpleDownloadManager.clearLidarrQueue();
if (result.removed > 0) {
console.log(
`Periodic Lidarr cleanup: removed ${result.removed} stuck download(s)`
);
}
} catch (err) {
console.error("Lidarr queue cleanup failed:", err);
}
}, 5 * 60 * 1000) // Every 5 minutes
);
console.log("Lidarr queue cleanup scheduled (every 5 minutes)");
// Run initial Lidarr cleanup 30 seconds after startup (to catch any stuck items)
timeouts.push(
setTimeout(async () => {
try {
console.log("Running initial Lidarr queue cleanup...");
const result = await simpleDownloadManager.clearLidarrQueue();
if (result.removed > 0) {
console.log(
`Initial cleanup: removed ${result.removed} stuck download(s)`
);
} else {
console.log("Initial cleanup: queue is clean");
}
} catch (err) {
console.error("Initial Lidarr cleanup failed:", err);
}
}, 30 * 1000) // 30 seconds after startup
);
/**
* Gracefully shutdown all workers and cleanup resources
*/
export async function shutdownWorkers(): Promise<void> {
console.log("Shutting down workers...");
// Stop unified enrichment worker
stopUnifiedEnrichmentWorker();
// Stop discover weekly cron
stopDiscoverWeeklyCron();
// Shutdown download queue manager
downloadQueueManager.shutdown();
// Clear all intervals
for (const interval of intervals) {
clearInterval(interval);
}
intervals.length = 0;
// Clear all timeouts
for (const timeout of timeouts) {
clearTimeout(timeout);
}
timeouts.length = 0;
// Remove all event listeners to prevent memory leaks
scanQueue.removeAllListeners();
discoverQueue.removeAllListeners();
imageQueue.removeAllListeners();
validationQueue.removeAllListeners();
// Close all queues gracefully
await Promise.all([
scanQueue.close(),
discoverQueue.close(),
imageQueue.close(),
validationQueue.close(),
]);
console.log("Workers shutdown complete");
}
// Export queues for use in other modules
export { scanQueue, discoverQueue, imageQueue, validationQueue };

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