v1.4.0: sequential enrichment, GPU auto-detection, repo cleanup

- Run audio analysis and vibe embedding phases sequentially to prevent
  resource contention (CPU/memory) from concurrent analyzers
- Auto-detect GPU availability in both audio analyzers (CUDA/ROCm)
- Fix false lite mode detection on startup by checking analyzer scripts
  on disk before falling back to heartbeat/DB checks
- Fix Dockerfile NEXT_PUBLIC_BACKEND_URL and frontend rewrite proxy
- Route enrichment failures through notification system instead of
  persistent error banner
- Remove playback error banner from player components
- Reduce enrichment cycle interval from 6h to 2h
- Comprehensive repo cleanup: remove 127 decorative comment dividers
  across 17 files, clean verbose comments, harden .gitignore, remove
  tracked docs from git

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-06 09:59:51 -06:00
parent cc3f1fb1ec
commit f2a443c6e3
34 changed files with 412 additions and 2042 deletions
+63 -320
View File
@@ -1,404 +1,135 @@
# =============================================================================
# LIDIFY MONOREPO - .gitignore
# =============================================================================
# =============================================================================
# Environment Variables & Secrets
# =============================================================================
# Environment & Secrets
.env
.env.*
!.env.example
*.local
.env.development.local
.env.test.local
.env.production.local
.env.local
.roomodes
# =============================================================================
# Dependencies
# =============================================================================
# Node modules in all subdirectories
**/node_modules/
node_modules/
jspm_packages/
# Python virtual environments (for soularr, scripts)
**/__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
.venv/
**/.venv/
# =============================================================================
# Build
# =============================================================================
# Frontend (Next.js)
# Build Outputs
frontend/.next/
frontend/out/
frontend/build/
frontend/dist/
# Backend (Node.js/TypeScript)
backend/dist/
backend/build/
backend/out/
# Mobile Application
mobile-application/build/
mobile-application/dist/
mobile-application/.expo/
mobile-application/.expo-shared/
# Soularr
soularr/dist/
soularr/build/
# General build outputs
**/dist/
**/build/
**/out/
.next
.nuxt
# =============================================================================
# Logs
# =============================================================================
logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
pnpm-debug.log*
backend/logs/
frontend/logs/
# =============================================================================
# Testing & Coverage
# =============================================================================
coverage/
*.lcov
.nyc_output
*.tsbuildinfo
.cache/
**/playwright-report/
**/test-results/
# =============================================================================
# Cache Directories
# =============================================================================
# Temporary & Backup Files
*.tmp
*.temp
*.bak
*.old
# Cache
.cache
.parcel-cache
.eslintcache
.stylelintcache
.npm
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.rpt2_cache_*/
.ruff_cache/
# =============================================================================
# Docker & Containers
# =============================================================================
# Don't ignore docker-compose.yml itself, but ignore local overrides
# Docker
docker-compose.override.yml
docker-compose.local.yml
# Docker volumes (if any are stored locally)
**/volumes/
**/data/
# =============================================================================
# IDEs & Editors
# =============================================================================
# VSCode
.vscode/
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
.vscode-test
# Claude Code
.claude/
.claude/*
!.claude/commands/
# JetBrains IDEs (WebStorm, IntelliJ, etc.)
.idea/
*.iml
*.iws
*.ipr
# Sublime Text
*.sublime-workspace
*.sublime-project
# Vim
*.swp
*.swo
*~
# Emacs
*~
\#*\#
.\#*
*.code-workspace
# macOS
# OS Files
.DS_Store
.AppleDouble
.LSOverride
._*
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
Desktop.ini
$RECYCLE.BIN/
# =============================================================================
# Runtime & Process Files
# =============================================================================
pids
# Runtime & Process
pids/
*.pid
*.seed
*.pid.lock
# =============================================================================
# Database Files (SQLite for local development)
# =============================================================================
# Database
*.sqlite
*.sqlite3
*.db
*.db-shm
*.db-wal
# Prisma
**/prisma/dev.db
**/prisma/dev.db-journal
# =============================================================================
# Media & Large Files
# =============================================================================
# Don't commit large music files (if any test files are added)
*.mp3
*.flac
*.wav
*.m4a
*.ogg
*.opus
# TypeScript
*.tsbuildinfo
# =============================================================================
# Secrets & Key Material
# =============================================================================
keystore.b64
keystore.jks
*.keystore
*.jks
# =============================================================================
# Runtime caches (backend)
# =============================================================================
backend/cache/
backend/logs/
backend/mullvad/
# =============================================================================
# Test artifacts
# =============================================================================
**/playwright-report/
**/test-results/
# Don't commit large images (unless they're assets)
# *.jpg
# *.jpeg
# *.png
# *.gif
# =============================================================================
# Temporary Files
# =============================================================================
*.tmp
*.temp
*.swp
*.swo
*.bak
*.old
# =============================================================================
# Package Manager Files
# =============================================================================
# Yarn
# Package Manager
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
yarn-error.log
# NPM
npm-debug.log*
# PNPM
pnpm-lock.yaml
.pnpm-debug.log*
# =============================================================================
# Mobile Specific (React Native / Expo)
# =============================================================================
mobile-application/.expo/
mobile-application/.expo-shared/
mobile-application/android/app/build/
mobile-application/ios/Pods/
mobile-application/ios/build/
mobile-application/*.jks
mobile-application/*.keystore
mobile-application/*.p8
mobile-application/*.p12
mobile-application/*.mobileprovision
# Secrets & Keys
*.keystore
*.jks
keystore.b64
**/key.txt
*.conf
lidify.keystore
# Legacy native leftovers (web app is PWA-first)
frontend/android/
# =============================================================================
# Postman (Keep collections, ignore environments with secrets)
# =============================================================================
postman/*environment*.json
postman/*.local.json
# BUT allow README.md files (case-insensitive)
!README.md
!readme.md
!Readme.md
# =============================================================================
# TypeScript
# =============================================================================
*.tsbuildinfo
tsconfig.tsbuildinfo
# =============================================================================
# Miscellaneous
# =============================================================================
.lock-wscript
lib-cov
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.grunt
bower_components
.serverless/
.fusebox/
.dynamodb/
.tern-port
.docusaurus
**/.vitepress/dist
**/.vitepress/cache
.vuepress/dist
.temp
*.tgz
.node_repl_history
# =============================================================================
# Project Specific
# =============================================================================
# Development scripts (keep locally, don't commit)
reset-and-setup.sh
organize-singles.sh
# AI Context Management (keep locally, don't push to GitHub)
context_portal/
# Internal Development Documentation (keep locally, don't push to GitHub)
docs/
**/docs/
# Temporary commit messages
COMMIT_MESSAGE.txt
# Backend development logs
backend/logs/
# Backend test cache directories
backend/cache/test-*/
# Backend duplicate/nested directories
backend/backend/
# Frontend Android build artifacts
frontend/android/build/
frontend/android/app/build/
# Postman collections (removed from repo)
postman/
# Soularr config (removed from repo)
soularr/
# Legacy React Native files (if re-added)
/App.tsx
/app.json
/src/
# =============================================================================
# IDE & Editor Settings
# =============================================================================
.claude/
**/.claude/
.cursor/
**/.cursor/
.vscode/
**/.vscode/
.roo/
**/.roo/
# =============================================================================
# Android Build Artifacts (contains local paths)
# =============================================================================
frontend/android/app/build/
frontend/android/build/
frontend/android/.gradle/
frontend/android/app/src/main/assets/capacitor.config.json
# =============================================================================
# Capacitor Generated Files
# =============================================================================
frontend/android/capacitor-cordova-android-plugins/build/
# =============================================================================
# Cache Files (user-specific data)
# =============================================================================
# Backend
backend/cache/
**/cache/covers/
**/cache/transcodes/
# =============================================================================
# VPN / Private Configs (NEVER commit these!)
# =============================================================================
backend/mullvad/
**/mullvad/
*.conf
**/key.txt
# Android signing
lidify.keystore
keystore.b64
# Frontend
frontend/android/
frontend/test-*.tsx
# =============================================================================
# AI Tools & Assistants
# =============================================================================
.claude/
**/.claude/
!.claude/commands/
.cursor/
**/.cursor/
.roo/
**/.roo/
.aider*
.serena/
**/.serena/
@@ -408,18 +139,30 @@ keystore.b64
**/.vibe/
.claudeignore
CLAUDE.md
AGENTS.md
# AI-generated planning/reports/documentation (keep locally)
# Internal Documentation (public docs are in project root)
docs/
**/docs/
context_portal/
issues/
plans/
systems/
# Development worktrees (keep locally, don't commit)
# Development
.worktrees/
reset-and-setup.sh
organize-singles.sh
COMMIT_MESSAGE.txt
backend/backend/
postman/
soularr/
# =============================================================================
# Development/Debug Test Files (keep locally, don't push to GitHub)
AGENTS.md
# =============================================================================
frontend/test-*.tsx
frontend/features/library/components/LazyArtistCard.tsx
# Legacy (removed from repo)
/App.tsx
/app.json
/src/
# Allow README files
!README.md
!readme.md
+61
View File
@@ -5,6 +5,39 @@ All notable changes to Lidify will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.0] - 2026-02-05
### Performance
- **Sequential audio/vibe enrichment:** Vibe phase skips when audio analysis is still running, preventing concurrent CPU-intensive Python analyzers from competing for resources
- **Faster enrichment cycles:** Reduced cycle interval from 30s to 5s; the rate limiter already handles API throttling, making the extra delay redundant
- **GPU auto-detection (CLAP):** PyTorch-based CLAP vibe embeddings auto-detect and use GPU when available, falling back to CPU
- **GPU auto-detection (Essentia):** TensorFlow-based audio analysis detects GPU with memory growth enabled, with device logging on startup
### Changed
- **Enrichment orchestration simplified:** Replaced 4 phase functions with duplicated stop/pause handling with a generic `runPhase()` executor and `shouldHaltCycle()` helper
### Fixed
- **Docker frontend routing:** Fixed `NEXT_PUBLIC_BACKEND_URL` build-time env var in Dockerfile so the frontend correctly proxies API requests to the backend
- **Next.js rewrite proxy:** Updated rewrite config to use `NEXT_PUBLIC_BACKEND_URL` for consistent build-time/runtime behavior
- **False lite mode on startup:** Feature detection now checks for analyzer scripts on disk, preventing false "lite mode" display before analyzers send their first heartbeat
- **Removed playback error banner:** Removed the red error bar from all player components (FullPlayer, MiniPlayer, OverlayPlayer) that displayed raw Howler.js error codes
- **Enrichment failure notifications:** Replaced aggressive per-cycle error banner with a single notification through the notification system when enrichment completes with failures
## [1.3.9] - 2026-02-04
### Fixed
- **Audio analysis cleanup:** Fixed race condition in audio analysis cleanup that could reset tracks still being processed
## [1.3.8] - 2026-02-03
### Fixed
- **Enrichment:** CLAP queue and failure cleanup fixes for enrichment debug mode
## [1.3.7] - 2026-02-01
### Added
@@ -69,6 +102,34 @@ Automatic detection of available analyzers with graceful degradation.
- **Docker Profiles:** Replaced Docker profiles with override file approach for better compatibility
- **Mood Columns:** Marked as legacy in schema - may be derived from CLAP embeddings in future
## [1.3.5] - 2026-01-22
### Fixed
- **Audio preload:** Emit preload 'load' event asynchronously to prevent race condition during gapless playback
## [1.3.4] - 2026-01-22
### Added
- **Gapless playback:** Preload infrastructure and next-track preloading for seamless transitions
- **Infinite scroll:** Library artists, albums, and tracks now use infinite query pagination
- **CachedImage:** Migrated to Next.js Image component with proper type support
### Fixed
- **CSS hover performance:** Fixed hover state performance issues
- **Audio analyzer:** Fixed Enhanced mode detection
- **Onboarding:** Accessibility improvements
- **Audio format detection:** Simplified to prevent wrong decoder attempts
- **Audio cleanup:** Improved Howl instance cleanup to prevent memory leaks
- **Audio cleanup tracking:** Use Set for pending cleanup tracking
- **Redis connections:** Disconnect enrichmentStateService connections on shutdown
### Changed
- **Library page:** Optimized data fetching with tab-based queries and memoized delete handlers
## [1.3.3] - 2026-01-18
Comprehensive patch release addressing critical stability issues, performance improvements, and production readiness fixes. This release includes community-contributed fixes and extensive internal code quality improvements.
+1 -1
View File
@@ -183,7 +183,7 @@ RUN npm ci && npm cache clean --force
COPY frontend/ ./
# Build Next.js (production)
ENV NEXT_PUBLIC_API_URL=
ENV NEXT_PUBLIC_BACKEND_URL=http://127.0.0.1:3006
RUN npm run build
# ============================================
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lidify-backend",
"version": "1.3.7",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lidify-backend",
"version": "1.3.7",
"version": "1.4.0",
"license": "GPL-3.0",
"dependencies": {
"@bull-board/api": "^6.14.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lidify-backend",
"version": "1.3.8",
"version": "1.4.0",
"description": "Lidify backend API server",
"license": "GPL-3.0",
"repository": {
-20
View File
@@ -58,10 +58,6 @@ function deezerRadioToUnified(radio: DeezerRadioStation): PlaylistPreview {
};
}
// ============================================
// Playlist Endpoints
// ============================================
/**
* GET /api/browse/playlists/featured
* Get featured/chart playlists from Deezer
@@ -138,10 +134,6 @@ router.get("/playlists/:id", async (req, res) => {
}
});
// ============================================
// Radio Endpoints
// ============================================
/**
* GET /api/browse/radios
* Get all radio stations (mood/theme based mixes)
@@ -215,10 +207,6 @@ router.get("/radios/:id", async (req, res) => {
}
});
// ============================================
// Genre Endpoints
// ============================================
/**
* GET /api/browse/genres
* Get all available genres
@@ -296,10 +284,6 @@ router.get("/genres/:id/playlists", async (req, res) => {
}
});
// ============================================
// URL Parsing (supports both Spotify & Deezer)
// ============================================
/**
* POST /api/browse/playlists/parse
* Parse a Spotify or Deezer URL and return playlist info
@@ -343,10 +327,6 @@ router.post("/playlists/parse", async (req, res) => {
}
});
// ============================================
// Combined Browse Endpoint (for frontend convenience)
// ============================================
/**
* GET /api/browse/all
* Get a combined view of featured content (playlists, genres)
+3 -9
View File
@@ -942,9 +942,8 @@ router.get("/artists/:id", async (req, res) => {
return res.status(404).json({ error: "Artist not found" });
}
// ========== DISCOGRAPHY HANDLING ==========
// For enriched artists with ownedAlbums, skip expensive MusicBrainz calls
// Only fetch from MusicBrainz if the artist hasn't been enriched yet
// For enriched artists with ownedAlbums, skip expensive MusicBrainz calls.
// Only fetch from MusicBrainz if the artist hasn't been enriched yet.
let albumsWithOwnership = [];
const ownedRgMbids = new Set(artist.ownedAlbums.map((o) => o.rgMbid));
const isEnriched =
@@ -994,8 +993,7 @@ router.get("/artists/:id", async (req, res) => {
}
}
// ========== ALWAYS include albums from database (actual owned files) ==========
// These are albums with actual tracks on disk - they MUST show as owned
// Albums from database have actual tracks on disk - they MUST show as owned
const dbAlbums = artist.albums.map((album) => ({
...album,
owned: true, // If it's in the database with tracks, user owns it!
@@ -1007,7 +1005,6 @@ router.get("/artists/:id", async (req, res) => {
`[Artist] Found ${dbAlbums.length} albums from database (actual owned files)`
);
// ========== Supplement with MusicBrainz discography for "available to download" ==========
// Always fetch discography if we have a valid MBID - users need to see what's available
const hasDbAlbums = dbAlbums.length > 0;
const shouldFetchDiscography =
@@ -1286,15 +1283,12 @@ router.get("/artists/:id", async (req, res) => {
}));
}
// ========== HERO IMAGE FETCHING ==========
// Use DataCacheService: DB -> Redis -> API -> save to both
const heroUrl = await dataCacheService.getArtistImage(
artist.id,
artist.name,
effectiveMbid
);
// ========== SIMILAR ARTISTS (from enriched JSON or Last.fm API) ==========
let similarArtists: any[] = [];
const similarCacheKey = `similar-artists:${artist.id}`;
-4
View File
@@ -440,10 +440,6 @@ router.post("/mood/save-preferences", async (req, res) => {
}
});
// ============================================
// NEW SIMPLIFIED MOOD BUCKET ENDPOINTS
// ============================================
/**
* @openapi
* /mixes/mood/buckets/presets:
-4
View File
@@ -132,10 +132,6 @@ router.post(
}
);
// ============================================
// Download History Endpoints
// ============================================
/**
* GET /notifications/downloads/history
* Get completed/failed downloads that haven't been cleared
-4
View File
@@ -537,10 +537,6 @@ router.put("/:id/items/reorder", async (req, res) => {
}
});
// ============================================
// Pending Tracks (from Spotify imports)
// ============================================
/**
* GET /playlists/:id/pending
* Get pending tracks for a playlist (tracks from Spotify that haven't been matched yet)
+2 -3
View File
@@ -115,9 +115,8 @@ router.get("/for-you", async (req, res) => {
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
// 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
@@ -21,10 +21,6 @@ import { lastFmService } from "./lastfm";
import { AcquisitionError, AcquisitionErrorType } from "./lidarr";
import PQueue from "p-queue";
// ============================================
// TYPE DEFINITIONS
// ============================================
/**
* Context for tracking acquisition origin
* Used to link download jobs to their source (Discovery batch or Spotify import)
@@ -89,10 +85,6 @@ interface DownloadBehavior {
fallbackSource: "soulseek" | "lidarr" | null;
}
// ============================================
// ACQUISITION SERVICE
// ============================================
class AcquisitionService {
private albumQueue: PQueue;
private lastConcurrency: number = 4;
+1 -15
View File
@@ -118,18 +118,14 @@ export class AudiobookCacheService {
* 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) {
logger.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;
@@ -148,22 +144,16 @@ export class AudiobookCacheService {
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
// Parse series name and sequence from seriesName string (e.g. "Series Name #2")
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"
@@ -189,7 +179,6 @@ export class AudiobookCacheService {
}
}
// Fallback: check metadata.series array/object format
if (!series) {
if (Array.isArray(metadata.series) && metadata.series.length > 0) {
series = metadata.series[0]?.name || null;
@@ -204,7 +193,6 @@ export class AudiobookCacheService {
}
}
// Log series info for debugging (only for first few books)
if (series) {
logger.debug(
` [Series] "${title}" -> "${series}" #${
@@ -213,10 +201,8 @@ export class AudiobookCacheService {
);
}
// 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(
-20
View File
@@ -11,10 +11,6 @@ import { redisClient } from "../utils/redis";
const DEEZER_API = "https://api.deezer.com";
// ============================================
// Playlist Types
// ============================================
export interface DeezerTrack {
deezerId: string;
title: string;
@@ -68,10 +64,6 @@ export interface DeezerGenreWithRadios {
radios: DeezerRadioStation[];
}
// ============================================
// Service Class
// ============================================
class DeezerService {
private readonly cachePrefix = "deezer:";
private readonly cacheTTL = 86400; // 24 hours
@@ -98,10 +90,6 @@ class DeezerService {
}
}
// ============================================
// Image & Preview Methods (existing functionality)
// ============================================
/**
* Search for an artist and get their image URL
*/
@@ -250,10 +238,6 @@ class DeezerService {
}
}
// ============================================
// Playlist Methods (new functionality)
// ============================================
/**
* Parse a Deezer URL and extract the type and ID
*/
@@ -477,10 +461,6 @@ class DeezerService {
return this.searchPlaylists(genreName, limit);
}
// ============================================
// Radio Methods
// ============================================
/**
* Get all radio stations (mood/theme based mixes)
* Cached for 24 hours
-17
View File
@@ -938,12 +938,6 @@ export class DiscoverWeeklyService {
return;
}
// ==============================================
// PLAYLIST COMPOSITION: ALL Discovery + ~20% Anchors
// ONE TRACK PER ALBUM - Each album contributes only 1 track
// Include ALL successfully downloaded albums!
// ==============================================
// Group tracks by album ID and pick ONE random track per album
const tracksByAlbum = new Map<string, typeof allTracks>();
for (const track of allTracks) {
@@ -2298,9 +2292,6 @@ export class DiscoverWeeklyService {
` Total similar artists from all seeds: ${allSimilarArtists.length}`
);
// ============================================
// PASS 1: NEW ARTISTS ONLY (true discovery)
// ============================================
logger.debug(`\n === PASS 1: NEW Artists Only ===`);
for (const sim of allSimilarArtists) {
@@ -2356,9 +2347,6 @@ export class DiscoverWeeklyService {
` Pass 1 complete: ${recommendations.length}/${targetCount} from NEW artists`
);
// ============================================
// PASS 2: EXISTING ARTISTS (fallback if needed)
// ============================================
if (
recommendations.length < targetCount &&
existingArtistsForFallback.length > 0
@@ -2609,11 +2597,6 @@ export class DiscoverWeeklyService {
};
}
// ============================================
// MULTI-STRATEGY DISCOVERY ENGINE
// Rotates weekly to keep recommendations fresh
// ============================================
/**
* Get user's top genres from listening history
*/
+15
View File
@@ -1,7 +1,12 @@
import { existsSync } from "fs";
import { redisClient } from "../utils/redis";
import { prisma } from "../utils/db";
import { logger } from "../utils/logger";
// Analyzer script paths in the Docker image
const ESSENTIA_ANALYZER_PATH = "/app/audio-analyzer/analyzer.py";
const CLAP_ANALYZER_PATH = "/app/audio-analyzer-clap/analyzer.py";
export interface AvailableFeatures {
musicCNN: boolean;
vibeEmbeddings: boolean;
@@ -37,6 +42,11 @@ class FeatureDetectionService {
private async checkMusicCNN(): Promise<boolean> {
try {
// Analyzer script bundled in image = feature is available
if (existsSync(ESSENTIA_ANALYZER_PATH)) {
return true;
}
const heartbeat = await redisClient.get("audio:worker:heartbeat");
if (heartbeat) {
const timestamp = parseInt(heartbeat, 10);
@@ -58,6 +68,11 @@ class FeatureDetectionService {
private async checkCLAP(): Promise<boolean> {
try {
// Analyzer script bundled in image = feature is available
if (existsSync(CLAP_ANALYZER_PATH)) {
return true;
}
const heartbeat = await redisClient.get("clap:worker:heartbeat");
if (heartbeat) {
const timestamp = parseInt(heartbeat, 10);
-29
View File
@@ -4,10 +4,6 @@ import { config } from "../config";
import { getSystemSettings } from "../utils/systemSettings";
import { stripAlbumEdition } from "../utils/artistNormalization";
// ============================================
// STRUCTURED ERROR TYPES
// ============================================
/**
* Error types for music acquisition failures
* Used to determine fallback strategies
@@ -1162,9 +1158,6 @@ class LidarrService {
// Use the verified album data
const updatedAlbum = verifyResponse.data;
// ============================================================
// PHASE 2.1: Proactive anyReleaseOk for edition variants
// ============================================================
const editionPatterns = [
/\(remaster/i,
/\(deluxe/i,
@@ -1264,9 +1257,6 @@ class LidarrService {
);
if (result.message?.includes("0 reports")) {
// ============================================================
// PHASE 2.3: Enhanced diagnostics for 0 reports
// ============================================================
try {
const albumDetails = await this.client.get(
`/api/v1/album/${updatedAlbum.id}`
@@ -1351,9 +1341,6 @@ class LidarrService {
);
if (retryResult.message?.includes("0 reports")) {
// ============================================================
// PHASE 2.2: Fallback to base album title
// ============================================================
const baseAlbumTitle = this.extractBaseTitle(albumTitle);
if (baseAlbumTitle !== albumTitle && baseAlbumTitle.length > 2) {
@@ -1797,10 +1784,6 @@ class LidarrService {
}
}
// ============================================
// Tag Management Methods (for discovery tracking)
// ============================================
/**
* Get all tags from Lidarr
*/
@@ -2097,10 +2080,6 @@ class LidarrService {
}
}
// ============================================
// Release Iteration Methods (for exhaustive retry)
// ============================================
/**
* Get all available releases for an album from all indexers
* This is what Lidarr's "Interactive Search" uses
@@ -2349,10 +2328,6 @@ class LidarrService {
}
}
// ============================================
// BATCH RECONCILIATION METHODS
// ============================================
/**
* Fetch all data needed for reconciliation in minimal API calls.
* Returns indexed Maps for O(1) lookups against job data.
@@ -2558,10 +2533,6 @@ export interface LidarrRelease {
export const lidarrService = new LidarrService();
// ============================================
// Queue Cleaner Functions
// ============================================
// Types for queue monitoring
interface QueueItem {
id: number;
+1 -14
View File
@@ -1513,9 +1513,7 @@ export class ProgrammaticPlaylistService {
};
}
// ============================================================
// AUDIO ANALYSIS-BASED MIXES (Using Essentia features)
// ============================================================
/**
* Generate "High Energy" mix using audio analysis
@@ -2200,9 +2198,7 @@ export class ProgrammaticPlaylistService {
};
}
// ============================================================
// LAST.FM TAG-BASED MIXES
// ============================================================
/**
* Generate mix based on Last.fm mood tags
@@ -2361,9 +2357,7 @@ export class ProgrammaticPlaylistService {
};
}
// ============================================================
// DAY-OF-WEEK MIXES
// ============================================================
/**
* Generate day-specific mix based on the current day
@@ -2531,10 +2525,7 @@ export class ProgrammaticPlaylistService {
};
}
// ============================================================
// CURATED VIBE MIXES (Daily, 10 tracks)
// These are "mood" mixes based on audio analysis and vibes
// ============================================================
/**
* "Sad Girl Sundays" - Melancholic introspection
@@ -3253,9 +3244,7 @@ export class ProgrammaticPlaylistService {
};
}
// ============================================================
// WEEKLY CURATED MIXES (20 tracks)
// ============================================================
/**
* "Deep Cuts" - Hidden gems from your library
@@ -3574,9 +3563,7 @@ export class ProgrammaticPlaylistService {
};
}
// ============================================================
// MOOD ON DEMAND - Generate a mix based on specific criteria
// ============================================================
// MOOD ON DEMAND
/**
* Generate a custom mood mix based on audio feature parameters
-1
View File
@@ -373,7 +373,6 @@ export async function enrichSimilarArtist(artist: Artist): Promise<void> {
);
}
// ========== ALBUM COVER ENRICHMENT ==========
// Fetch covers for all albums belonging to this artist that don't have covers yet
await enrichAlbumCovers(artist.id, localHeroUrl);
+119 -175
View File
@@ -29,7 +29,7 @@ import pLimit from "p-limit";
// Configuration
const ARTIST_BATCH_SIZE = 10;
const TRACK_BATCH_SIZE = 20;
const ENRICHMENT_INTERVAL_MS = 30 * 1000; // 30 seconds
const ENRICHMENT_INTERVAL_MS = 5 * 1000; // 5 seconds - rate limiter handles API limits
const MAX_CONSECUTIVE_SYSTEM_FAILURES = 5; // Circuit breaker threshold
let isRunning = false;
@@ -55,6 +55,9 @@ let currentBatchFailures: BatchFailures = {
audio: [],
};
// Session-level failure counter (accumulates across cycles, reset on enrichment start)
let sessionFailureCount = { artists: 0, tracks: 0, audio: 0 };
// Mood tags to extract from Last.fm
const MOOD_TAGS = new Set([
// Energy/Activity
@@ -215,6 +218,15 @@ export async function startUnifiedEnrichmentWorker() {
logger.debug(` Interval: ${ENRICHMENT_INTERVAL_MS / 1000}s`);
logger.debug("");
// Check if there's existing state that might be problematic
const existingState = await enrichmentStateService.getState();
// Only clear state if it exists and is in a non-idle state
// This prevents clearing fresh state from a previous worker instance
if (existingState && existingState.status !== "idle") {
await enrichmentStateService.clear();
}
// Initialize state
await enrichmentStateService.initializeState();
@@ -377,88 +389,68 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{
tracks: number;
audioQueued: number;
}> {
// Check if paused
if (isPaused) {
return { artists: 0, tracks: 0, audioQueued: 0 };
}
const emptyResult = { artists: 0, tracks: 0, audioQueued: 0 };
// Check state service
// Sync local pause flag with state service
if (!isPaused) {
const state = await enrichmentStateService.getState();
if (state?.status === "paused" || state?.status === "stopping") {
isPaused = true;
return { artists: 0, tracks: 0, audioQueued: 0 };
}
}
// Allow immediate enrichment requests to bypass the isRunning check
// This prevents race conditions when new content is imported
if (isRunning && !fullMode && !immediateEnrichmentRequested) {
return { artists: 0, tracks: 0, audioQueued: 0 };
if (isPaused) {
return emptyResult;
}
// Enforce minimum interval between cycles (unless full mode or immediate request)
// Skip if already running (unless full mode or immediate request)
const bypassRunningCheck = fullMode || immediateEnrichmentRequested;
if (isRunning && !bypassRunningCheck) {
return emptyResult;
}
// Enforce minimum interval (unless full mode or immediate request)
const now = Date.now();
if (
!fullMode &&
!immediateEnrichmentRequested &&
now - lastRunTime < MIN_INTERVAL_MS
) {
return { artists: 0, tracks: 0, audioQueued: 0 };
if (!bypassRunningCheck && now - lastRunTime < MIN_INTERVAL_MS) {
return emptyResult;
}
// Clear the immediate request flag
immediateEnrichmentRequested = false;
lastRunTime = now;
isRunning = true;
let artistsProcessed = 0;
let tracksProcessed = 0;
let audioQueued = 0;
try {
// Reset system failure counter on successful cycle start
consecutiveSystemFailures = 0;
const artistsPhase = await enrichArtistsPhase();
if (!artistsPhase.shouldContinue) {
return {
artists: artistsPhase.result,
tracks: 0,
audioQueued: 0,
};
// Run phases sequentially, halting if stopped/paused
const artistResult = await runPhase("artists", executeArtistsPhase);
if (artistResult === null) {
return { artists: 0, tracks: 0, audioQueued: 0 };
}
artistsProcessed = artistsPhase.result;
artistsProcessed = artistResult;
const moodTagsPhase = await enrichMoodTagsPhase();
if (!moodTagsPhase.shouldContinue) {
return {
artists: artistsProcessed,
tracks: moodTagsPhase.result,
audioQueued: 0,
};
const trackResult = await runPhase("tracks", executeMoodTagsPhase);
if (trackResult === null) {
return { artists: artistsProcessed, tracks: 0, audioQueued: 0 };
}
tracksProcessed = moodTagsPhase.result;
tracksProcessed = trackResult;
const audioPhase = await enrichAudioPhase();
if (!audioPhase.shouldContinue) {
return {
artists: artistsProcessed,
tracks: tracksProcessed,
audioQueued: audioPhase.result,
};
const audioResult = await runPhase("audio", executeAudioPhase);
if (audioResult === null) {
return { artists: artistsProcessed, tracks: tracksProcessed, audioQueued: 0 };
}
audioQueued = audioPhase.result;
audioQueued = audioResult;
const vibePhase = await enrichVibePhase();
if (!vibePhase.shouldContinue) {
return {
artists: artistsProcessed,
tracks: tracksProcessed,
audioQueued: audioQueued,
};
const vibeResult = await runPhase("vibe", executeVibePhase);
if (vibeResult === null) {
return { artists: artistsProcessed, tracks: tracksProcessed, audioQueued };
}
const vibeQueued = vibeResult;
const features = await featureDetection.getFeatures();
const vibeQueued = vibePhase.result;
// Log progress (only if work was done)
if (artistsProcessed > 0 || tracksProcessed > 0 || audioQueued > 0 || vibeQueued > 0) {
@@ -500,51 +492,18 @@ try {
},
completionNotificationSent: false, // Reset flag when new work is processed
});
// Reset session failure counter when new work begins
sessionFailureCount = { artists: 0, tracks: 0, audio: 0 };
}
// Send failure notification if there were any failures in this batch
const totalFailures =
currentBatchFailures.artists.length +
currentBatchFailures.tracks.length +
currentBatchFailures.audio.length;
// Accumulate cycle failures into session counter before resetting
sessionFailureCount.artists += currentBatchFailures.artists.length;
sessionFailureCount.tracks += currentBatchFailures.tracks.length;
sessionFailureCount.audio += currentBatchFailures.audio.length;
if (totalFailures > 0) {
try {
const failureCounts =
await enrichmentFailureService.getFailureCounts();
const { notificationService } =
await import("../services/notificationService");
const users = await prisma.user.findMany({
select: { id: true },
});
for (const user of users) {
await notificationService.create({
userId: user.id,
type: "error",
title: "Enrichment Completed with Errors",
message: `${failureCounts.total} items failed enrichment. Click to view and retry.`,
metadata: {
actionUrl: "/settings#enrichment-failures",
actionLabel: "View Failures",
failureCounts,
},
});
}
logger.debug(
`[Enrichment] Failure notification sent: ${totalFailures} failures in batch`,
);
} catch (error) {
logger.error(
"[Enrichment] Failed to send failure notification:",
error,
);
}
// Reset batch failures
// Reset batch failures (failures are viewable in Settings > Enrichment)
currentBatchFailures = { artists: [], tracks: [], audio: [] };
}
// If everything is complete, mark as idle and send notification (only once)
const progress = await getEnrichmentProgress();
@@ -613,7 +572,26 @@ try {
const users = await prisma.user.findMany({
select: { id: true },
});
const totalSessionFailures =
sessionFailureCount.artists +
sessionFailureCount.tracks +
sessionFailureCount.audio;
for (const user of users) {
if (totalSessionFailures > 0) {
const parts: string[] = [];
if (sessionFailureCount.artists > 0) parts.push(`${sessionFailureCount.artists} artist(s)`);
if (sessionFailureCount.tracks > 0) parts.push(`${sessionFailureCount.tracks} track(s)`);
if (sessionFailureCount.audio > 0) parts.push(`${sessionFailureCount.audio} audio analysis`);
await notificationService.create({
userId: user.id,
type: "error",
title: "Enrichment Completed with Errors",
message: `${totalSessionFailures} failures: ${parts.join(", ")}. Check Settings > Enrichment for details.`,
});
}
await notificationService.notifySystem(
user.id,
"Enrichment Complete",
@@ -1035,63 +1013,52 @@ async function queueVibeEmbeddings(): Promise<number> {
return queued;
}
interface EnrichmentPhaseResult {
result: number;
shouldContinue: boolean;
/**
* Check if enrichment should stop and handle state cleanup if stopping.
* Returns true if cycle should halt (either stopping or paused).
*/
async function shouldHaltCycle(): Promise<boolean> {
if (isStopping) {
await enrichmentStateService.updateState({
status: "idle",
currentPhase: null,
});
isStopping = false;
return true;
}
return isPaused;
}
async function enrichArtistsPhase(): Promise<EnrichmentPhaseResult> {
/**
* Run a phase and return result. Returns null if cycle should halt.
*/
async function runPhase(
phaseName: "artists" | "tracks" | "audio" | "vibe",
executor: () => Promise<number>,
): Promise<number | null> {
await enrichmentStateService.updateState({
status: "running",
currentPhase: "artists",
currentPhase: phaseName,
});
const result = await enrichArtistsBatch();
const result = await executor();
if (isStopping) {
await enrichmentStateService.updateState({
status: "idle",
currentPhase: null,
});
isStopping = false;
return { result, shouldContinue: false };
if (await shouldHaltCycle()) {
return null;
}
if (isPaused) {
return { result, shouldContinue: false };
return result;
}
return { result, shouldContinue: true };
async function executeArtistsPhase(): Promise<number> {
return enrichArtistsBatch();
}
async function enrichMoodTagsPhase(): Promise<EnrichmentPhaseResult> {
await enrichmentStateService.updateState({
currentPhase: "tracks",
});
const result = await enrichTrackTagsBatch();
if (isStopping) {
await enrichmentStateService.updateState({
status: "idle",
currentPhase: null,
});
isStopping = false;
return { result, shouldContinue: false };
async function executeMoodTagsPhase(): Promise<number> {
return enrichTrackTagsBatch();
}
if (isPaused) {
return { result, shouldContinue: false };
}
return { result, shouldContinue: true };
}
async function enrichAudioPhase(): Promise<EnrichmentPhaseResult> {
await enrichmentStateService.updateState({
currentPhase: "audio",
});
async function executeAudioPhase(): Promise<number> {
const audioCompletedBefore = await prisma.track.count({
where: { analysisStatus: "completed" },
});
@@ -1111,40 +1078,32 @@ async function enrichAudioPhase(): Promise<EnrichmentPhaseResult> {
audioAnalysisCleanupService.recordSuccess();
}
let result = 0;
if (audioAnalysisCleanupService.isCircuitOpen()) {
logger.warn(
"[Enrichment] Audio analysis circuit breaker OPEN - skipping queue",
);
} else {
result = await queueAudioAnalysis();
return 0;
}
if (isStopping) {
await enrichmentStateService.updateState({
status: "idle",
currentPhase: null,
});
isStopping = false;
return { result, shouldContinue: false };
return queueAudioAnalysis();
}
if (isPaused) {
return { result, shouldContinue: false };
}
return { result, shouldContinue: true };
}
async function enrichVibePhase(): Promise<EnrichmentPhaseResult> {
async function executeVibePhase(): Promise<number> {
const features = await featureDetection.getFeatures();
if (!features.vibeEmbeddings) {
return { result: 0, shouldContinue: true };
return 0;
}
await enrichmentStateService.updateState({
currentPhase: "vibe",
const audioProcessing = await prisma.track.count({
where: { analysisStatus: "processing" },
});
const audioQueue = await getRedis().llen("audio:analysis:queue");
if (audioProcessing > 0 || audioQueue > 0) {
logger.debug(
`[Enrichment] Skipping vibe phase - audio still running (${audioProcessing} processing, ${audioQueue} queued)`,
);
return 0;
}
const { reset } = await vibeAnalysisCleanupService.cleanupStaleProcessing();
if (reset > 0) {
@@ -1153,25 +1112,10 @@ async function enrichVibePhase(): Promise<EnrichmentPhaseResult> {
const result = await queueVibeEmbeddings();
if (result > 0) {
logger.debug(
`[ENRICHMENT] Queued ${result} tracks for vibe embedding`
);
logger.debug(`[ENRICHMENT] Queued ${result} tracks for vibe embedding`);
}
if (isStopping) {
await enrichmentStateService.updateState({
status: "idle",
currentPhase: null,
});
isStopping = false;
return { result, shouldContinue: false };
}
if (isPaused) {
return { result, shouldContinue: false };
}
return { result, shouldContinue: true };
return result;
}
/**
File diff suppressed because it is too large Load Diff
-28
View File
@@ -21,10 +21,6 @@ import {
AudioWaveform,
} from "lucide-react";
// ============================================
// TYPES
// ============================================
interface TrackFeatures {
energy: number;
valence: number;
@@ -132,9 +128,6 @@ function distanceToSimilarity(distance: number): number {
return Math.max(0, 1 - distance / 2);
}
// ============================================
// COVER IMAGE
// ============================================
function CoverImage({
coverUrl,
title,
@@ -182,9 +175,6 @@ function CoverImage({
);
}
// ============================================
// SIMILARITY BADGE - the signature element
// ============================================
function SimilarityBadge({ similarity, size = "md" }: { similarity: number; size?: "sm" | "md" | "lg" }) {
const percent = Math.round(similarity * 100);
const sizeClasses = {
@@ -227,9 +217,6 @@ function SimilarityBadge({ similarity, size = "md" }: { similarity: number; size
);
}
// ============================================
// FEATURE COMPARISON - side by side bars
// ============================================
function FeatureComparison({
source,
match,
@@ -278,9 +265,6 @@ function FeatureComparison({
);
}
// ============================================
// MOOD GRID - compact mood comparison
// ============================================
function MoodGrid({ source, match }: { source: TrackData; match: TrackData }) {
const validMoods = MOOD_CONFIG.filter(({ key }) => {
const sVal = source.features[key as keyof TrackFeatures];
@@ -319,9 +303,6 @@ function MoodGrid({ source, match }: { source: TrackData; match: TrackData }) {
);
}
// ============================================
// TAG PILLS - shared tags highlighted
// ============================================
function TagPills({ source, match }: { source: TrackData; match: TrackData }) {
const sourceTags = source.lastfmTags || [];
const matchTags = match.lastfmTags || [];
@@ -360,9 +341,6 @@ function TagPills({ source, match }: { source: TrackData; match: TrackData }) {
);
}
// ============================================
// COMPARISON PANEL - the detailed view
// ============================================
function ComparisonPanel({
source,
match,
@@ -478,9 +456,6 @@ function ComparisonPanel({
);
}
// ============================================
// TRACK ROW
// ============================================
function TrackRow({
track,
index,
@@ -567,9 +542,6 @@ function TrackRow({
);
}
// ============================================
// MAIN PAGE
// ============================================
export default function VibePage() {
const { vibeEmbeddings, loading: featuresLoading } = useFeatures();
+1 -34
View File
@@ -22,9 +22,7 @@ import {
RotateCw,
Loader2,
AudioWaveform,
AlertTriangle,
RefreshCw,
X,
} from "lucide-react";
import { useState, useMemo } from "react";
import { toast } from "sonner";
@@ -251,38 +249,7 @@ export function FullPlayer() {
return (
<div className="relative flex-shrink-0">
{/* Error Banner */}
{audioError && (
<div className="bg-red-500/20 border-t border-red-500/30 px-4 py-1.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span className="text-red-200 text-sm truncate">
{audioError}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => {
clearAudioError();
resume();
}}
className="px-2 py-1 text-xs text-red-200 hover:text-white hover:bg-red-500/30 transition rounded"
aria-label="Retry playback"
>
Retry
</button>
<button
onClick={clearAudioError}
className="p-1 text-red-300 hover:text-white transition rounded"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)}
<div className={cn("bg-black border-t border-white/[0.08]", audioError ? "h-20" : "h-24")}>
<div className="bg-black border-t border-white/[0.08] h-24">
{/* Subtle top glow */}
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
<div className="flex items-center h-full px-6 gap-6">
-39
View File
@@ -24,7 +24,6 @@ import {
ChevronUp,
ChevronDown,
AlertTriangle,
X,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
@@ -208,9 +207,6 @@ export function MiniPlayer() {
const seekEnabled = hasMedia && canSeek;
// ============================================
// MOBILE/TABLET: Spotify-style compact player
// ============================================
if (isMobileOrTablet) {
// Don't render if no media
if (!hasMedia) return null;
@@ -502,9 +498,6 @@ export function MiniPlayer() {
);
}
// ============================================
// DESKTOP: Full-featured mini player
// ============================================
return (
<div className="relative">
{/* Collapsible Vibe Panel - slides up from player */}
@@ -570,38 +563,6 @@ export function MiniPlayer() {
className="absolute top-0 left-0 right-0"
/>
{/* Error Banner */}
{audioError && (
<div className="bg-red-500/20 border-b border-red-500/30 px-3 py-1.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span className="text-red-200 text-xs truncate">
{audioError}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => {
clearAudioError();
resume();
}}
className="p-1 text-red-300 hover:text-white transition rounded"
aria-label="Retry playback"
title="Retry"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button
onClick={clearAudioError}
className="p-1 text-red-300 hover:text-white transition rounded"
aria-label="Dismiss error"
title="Dismiss"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{/* Player Content */}
<div className="px-3 py-2.5 pt-3">
@@ -19,9 +19,7 @@ import {
Loader2,
RotateCcw,
RotateCw,
AlertTriangle,
RefreshCw,
X,
} from "lucide-react";
import { formatTime, clampTime, formatTimeRemaining } from "@/utils/formatTime";
import { cn } from "@/utils/cn";
@@ -340,24 +338,6 @@ export function OverlayPlayer() {
</p>
)}
{/* Error Banner */}
{audioError && (
<div className="mt-3 bg-red-500/20 border border-red-500/30 rounded-lg px-3 py-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span className="text-red-200 text-sm truncate">
{audioError}
</span>
</div>
<button
onClick={clearAudioError}
className="p-1 text-red-300 hover:text-white transition rounded flex-shrink-0"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Progress Bar */}
-59
View File
@@ -20,12 +20,6 @@ import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from "@tansta
import { api } from "@/lib/api";
import type { Artist, Album, Track } from "@/features/library/types";
// ============================================================================
// QUERY KEY FACTORIES
// ============================================================================
// These functions generate consistent query keys for caching
// See: https://tanstack.com/query/latest/docs/react/guides/query-keys
export const queryKeys = {
// Artist queries
artist: (id: string) => ["artist", id] as const,
@@ -102,10 +96,6 @@ export const queryKeys = {
browseRadios: (limit?: number) => ["browse", "radios", limit] as const,
};
// ============================================================================
// ARTIST QUERIES
// ============================================================================
/**
* Hook to fetch artist data with automatic library/discovery fallback
*
@@ -174,10 +164,6 @@ export function useArtistDiscoveryQuery(nameOrMbid: string | undefined) {
});
}
// ============================================================================
// ALBUM QUERIES
// ============================================================================
/**
* Hook to fetch album data with automatic library/discovery fallback
*
@@ -266,10 +252,6 @@ export function useAlbumsQuery(params?: {
});
}
// ============================================================================
// LIBRARY QUERIES
// ============================================================================
/**
* Hook to fetch recently listened items (Continue Listening)
*
@@ -308,10 +290,6 @@ export function useRecentlyAddedQuery(limit: number = 10) {
});
}
// ============================================================================
// LIBRARY PAGE QUERIES (Artists/Albums/Tracks with pagination)
// ============================================================================
export type LibraryFilter = "owned" | "discovery" | "all";
export type SortOption = "name" | "name-desc" | "recent" | "tracks";
@@ -592,10 +570,6 @@ export function useLibraryTracksQuery({
});
}
// ============================================================================
// RECOMMENDATION QUERIES
// ============================================================================
/**
* Hook to fetch personalized recommendations
*
@@ -662,10 +636,6 @@ export function useSimilarAlbumsQuery(
});
}
// ============================================================================
// SEARCH QUERIES
// ============================================================================
/**
* Hook to search library with debouncing
*
@@ -722,10 +692,6 @@ export function useDiscoverSearchQuery(
});
}
// ============================================================================
// PLAYLIST QUERIES
// ============================================================================
/**
* Hook to fetch all playlists
*
@@ -765,10 +731,6 @@ export function usePlaylistQuery(id: string | undefined) {
});
}
// ============================================================================
// MIX QUERIES
// ============================================================================
/**
* Hook to fetch all mixes (Made For You)
*
@@ -805,10 +767,6 @@ export function useMixQuery(id: string | undefined) {
});
}
// ============================================================================
// POPULAR ARTISTS QUERY
// ============================================================================
/**
* Hook to fetch popular artists from Last.fm
*
@@ -828,10 +786,6 @@ export function usePopularArtistsQuery(limit: number = 20) {
});
}
// ============================================================================
// AUDIOBOOK QUERIES
// ============================================================================
/**
* Hook to fetch all audiobooks
*
@@ -863,10 +817,6 @@ export function useAudiobookQuery(id: string | undefined) {
});
}
// ============================================================================
// PODCAST QUERIES
// ============================================================================
/**
* Hook to fetch all subscribed podcasts
*
@@ -930,11 +880,6 @@ export function useTopPodcastsQuery(limit: number = 20, genreId?: number) {
});
}
// ============================================================================
// MUTATION HOOKS
// ============================================================================
// These hooks handle data modifications and automatically invalidate related queries
/**
* Hook to refresh mixes with cache invalidation
*
@@ -1041,10 +986,6 @@ export function useDeletePlaylistMutation() {
});
}
// ============================================================================
// BROWSE QUERIES (Deezer Playlists/Radios)
// ============================================================================
interface PlaylistPreview {
id: string;
source: string;
-4
View File
@@ -1702,10 +1702,6 @@ class ApiClient {
return this.delete(`/api-keys/${id}`);
}
// ============================================
// Notifications & Activity Panel
// ============================================
async getNotifications(): Promise<
Array<{
id: string;
+3 -1
View File
@@ -86,8 +86,10 @@ const nextConfig: NextConfig = {
];
},
// Proxy API requests to backend (for Docker all-in-one container)
// Use NEXT_PUBLIC_BACKEND_URL if set (build-time), otherwise default to localhost:3006
// At runtime, Next.js will proxy /api/* requests to the backend
async rewrites() {
const backendUrl = process.env.BACKEND_URL || "http://127.0.0.1:3006";
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://127.0.0.1:3006";
return [
{
+63 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lidify-frontend",
"version": "1.3.7",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lidify-frontend",
"version": "1.3.7",
"version": "1.4.0",
"license": "GPL-3.0",
"dependencies": {
"@tanstack/react-query": "^5.90.10",
@@ -1576,6 +1576,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -3673,6 +3733,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lidify-frontend",
"version": "1.3.8",
"version": "1.4.0",
"description": "Lidify web frontend",
"license": "GPL-3.0",
"repository": {
+21 -1
View File
@@ -2,5 +2,25 @@
"name": "lidify",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"devDependencies": {
"typescript": "^5.9.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"typescript": "^5.9.3"
}
}
+14 -3
View File
@@ -43,6 +43,14 @@ os.environ['NUMEXPR_MAX_THREADS'] = str(THREADS_PER_WORKER)
import torch
torch.set_num_threads(THREADS_PER_WORKER)
# Device detection - use GPU if available
if torch.cuda.is_available():
DEVICE = torch.device('cuda')
GPU_NAME = torch.cuda.get_device_name(0)
else:
DEVICE = torch.device('cpu')
GPU_NAME = None
import redis
import psycopg2
from psycopg2.extras import RealDictCursor
@@ -104,10 +112,13 @@ class CLAPAnalyzer:
)
self.model.load_ckpt('/app/models/music_audioset_epoch_15_esc_90.14.pt')
# Move to CPU explicitly (we don't use GPU in this service)
self.model = self.model.eval()
# Move to detected device (GPU if available, else CPU)
self.model = self.model.to(DEVICE).eval()
logger.info("CLAP model loaded successfully")
if GPU_NAME:
logger.info(f"CLAP model loaded successfully on GPU: {GPU_NAME}")
else:
logger.info("CLAP model loaded successfully on CPU")
except Exception as e:
logger.error(f"Failed to load CLAP model: {e}")
traceback.print_exc()
+18 -8
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
"""Audio analyzer service - Essentia-based analysis with TensorFlow ML models"""
# ============================================================================
# CRITICAL: TensorFlow threading MUST be configured before any imports
# Environment variables are read by TensorFlow C++ runtime before initialization
# ============================================================================
# CRITICAL: TensorFlow threading MUST be configured before any imports.
# Environment variables are read by TensorFlow C++ runtime before initialization.
import os
import sys
@@ -111,19 +109,31 @@ except ImportError as e:
# TensorFlow models via Essentia
TF_MODELS_AVAILABLE = False
TF_GPU_AVAILABLE = False
TF_GPU_NAME = None
TensorflowPredictMusiCNN = None
try:
import tensorflow as tf
# Limit TensorFlow memory usage (CPU & GPU)
try:
# Detect and configure GPU
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
TF_GPU_AVAILABLE = True
TF_GPU_NAME = gpus[0].name
# Enable memory growth to prevent TF from allocating all GPU memory
for gpu in gpus:
try:
tf.config.experimental.set_memory_growth(gpu, True)
except Exception:
except RuntimeError:
pass
logger.info(f"TensorFlow GPU detected: {TF_GPU_NAME}")
else:
logger.info("TensorFlow running on CPU")
from essentia.standard import TensorflowPredictMusiCNN
TF_MODELS_AVAILABLE = True
logger.info("TensorflowPredictMusiCNN available - Enhanced mode enabled")
device_str = f"GPU: {TF_GPU_NAME}" if TF_GPU_AVAILABLE else "CPU"
logger.info(f"TensorflowPredictMusiCNN available - Enhanced mode enabled ({device_str})")
except ImportError as e:
logger.warning(f"TensorflowPredictMusiCNN not available: {e}")
logger.info("Falling back to Standard mode")