Implementing a Spotify Connect-like remote playback feature that allows controlling music playback across multiple devices on the same LAN.
┌─────────────────┐ WebSocket ┌─────────────────┐
│ Device A │◄──────────────────►│ Lidify Server │
│ (Controller) │ Socket.io │ (Express:3006) │
└─────────────────┘ └────────┬────────┘
│
┌─────────────────┐ WebSocket │
│ Device B │◄───────────────────────────┘
│ (Active Player)│
└─────────────────┘
This section was added by reviewing the recent commit stream directly (git log -n 90) and consolidating major work that was not fully captured above.
- Introduced Last.fm-driven genre tagging flow and admin/script support for tagging (
327e915,3f0b702) - Polished and redesigned
/radioUX with better controls and presentation (327e915,3f0b702) - Continued UI consistency by centralizing semantic color usage via Tailwind v4
@themetokens (fad0bf1)
- Added backend YouTube Music service + library endpoints (
bcbc42e) - Added frontend source tracking and API support (
1255691) - Implemented fallback playback in player engine (
3af296e) - Prioritized local-file playback when
filePathexists to avoid unnecessary fallback (9667eea,5b07f16,4a5304b) - Added source badges in player UI (
b3ba902) - Added pre-cache for near-instant start and smoother queue transitions (
1116774) - Improved YouTube download and metadata/tag extraction workflows (
72baac8,6514f82)
- Completed core remote playback infrastructure and command/state plumbing (
f6813f1) - Improved bidirectional sync and controller/target consistency (
c05836f) - Fixed reconnect behavior where playback could stop unexpectedly (
cf5c24f) - Reduced noise by removing verbose remote debug logs once stable (
6ec5d7a)
- Added AI-powered ad-removal pipeline and progress/notification improvements (
faa3927) - Added per-subscription automation (auto-download, auto-remove-ads), M3U export, and scheduled refresh (
d067214) - Added per-podcast access tokens + external RSS feed support (
82d5f6b) - Added URL-based subscription path and compact subscription UI components (
4e8d05d,5cb9841)
- Reworked discovery into mode-driven UX (safe/adjacent/adventurous/mix), preview-first interactions, and better defaults (
fb637bb,f21bfb1,f8a3541) - Unified home/discover recommendation surfaces and simplified recommendation internals (
9aead4e,0a41959) - Improved AI Weekly signal quality and filtering with sonic similarity upgrades (
833ba40)
- Added dual-root path safeguards and prevented destructive scanner edge cases (
962b54c,e8e4fe5,d14964d) - Improved scanner metadata sanitization, duplicate prevention, and release-year handling (
8d260d4,6973290,c2b8290) - Fixed play-tracking + scanner cascade issues (
7d81b1a)
- Added search vectors/triggers and improved ranking + Last.fm integration (
971bb20,ac1b7ab) - Added MBID edit flow, genre support improvements, and temp-MBID handling fixes (
575abe0,9ef1919) - Added disc number support for multi-disc albums across backend/frontend/subsonic surfaces (
2674b03) - Added interactive release selection for manual download grabs (
be8a79b)
- Added
getAlbumInfo2/getArtistInfo2and additional Subsonic request handling/logging (067f534) - Added compatibility hardening for client behavior differences (
de28f1a) - Added ntfy push integration for downstream client auto-sync flows (Symfonium-style workflows) (
f0c710b)
- Large TS/ESLint cleanup passes across backend/frontend (
b0167e2,e67da2e,5d11ae9) - Dependency security updates via npm audit fixes (
14b9b5c) - CI/container pipeline improvements (GHCR + tag-based publishing) and deploy docs/examples refresh (
632683e,482cbbd,49ce24c,d85bfde)
3f0b702feat: genre tagging improvements and Radio page polish327e915feat: Last.fm genre tagging and radio page redesignfad0bf1refactor: centralize color system with Tailwind v4 @theme tokens
6514f82feat: YouTube streaming improvements and mobile player fixesf854edefeat: artist-focused recommendations, performance improvements, and UX enhancements
14b9b5cfix: resolve npm audit vulnerabilities1116774feat: YouTube streaming with pre-cache for instant playback
72baac8feat: save playlists without download + YouTube download improvements823a20afix: multiple playback, playlist, and UX improvements5d11ae9fix: replace 'any' types in page componentse67da2efix: resolve ESLint and TypeScript errors (340 -> 76)3189f4bfix: show correct source (Spotify/Deezer) in playlist detail page
7ca33c5feat: add Spotify playlist detail support4a5304bfix: include filePath in artist track playback for local streamingf6813f1feat: complete remote playback infrastructurec05836ffix: improve bidirectional remote playback synchronizationb0167e2fix: resolve 235 TypeScript errors across backend and frontendcfcf1f8feat(downloads): add toggles to enable/disable Soulseek and YouTube sources5b07f16fix(youtube): pass filePath in playlists page and skip play logging for external tracks8ad9189feat(browse): enable full playback for Deezer playlists via YouTubeb3ba902feat(player): show YouTube source indicator badge9667eeafix(streaming): pass filePath to use local files over YouTube fallback3af296efeat(youtube): implement YouTube fallback in audio player1255691feat(youtube): add frontend API client and audio source trackingbcbc42efeat(youtube): add YouTube Music streaming service and API endpoints6ec5d7achore: remove noisy RemoteIntegration debug logs
d14964dfix(downloads): namespace playlist downloads and improve streaming paths229bda7refactor(import): simplify to track-only Soulseek downloads48d3d68feat(soulseek): add rate limiting, user reputation, and improved matchingc4eff9afeat(browse): add Spotify as second source with combined searchbd0121fchore: UI cleanup and code simplification56de02dfeat: deezer pagination, playlist pending removal, mood mix staleness5cb9841feat(podcasts): add compact subscription list componentse8e4fe5feat(downloads): namespace playlist downloads for dual-root safety962b54cfix(scanner): prevent catastrophic data loss with dual-root paths4e8d05dfeat(podcasts): add podcast by URL subscription
82d5f6bfeat(podcasts): per-podcast access tokens and RSS feed for external appsd067214feat(podcasts): auto-download, ad-removal, M3U export, and scheduled refreshfaa3927feat(podcasts): AI-powered ad removal and playback improvements
de28f1afix(subsonic): improve client compatibilityfb637bbfeat(discover): AI-powered discovery redesign with mode controls
f8a3541fix(recommendations): change default timeframe from 7 days to 4 weeks
49ce24cchore: documentation, CI, and config updates91145ccrefactor(frontend): UI cleanup and settings improvements73afafbfix(backend): playlist diversity and data integrity improvements9d92b18docs: add discovery UX redesign plan and handoff notes0a41959refactor(backend): simplify recommendation algorithms9aead4efeat(frontend): unify main page and discover recommendationsf21bfb1feat(discover): rewrite as preview-only mode with auto-load7d81b1afix(backend): fix play tracking and scanner cascade bugs
c2b8290fix(artist): sort same-year albums by release date (newest first)6973290fix(scanner): prefer original release year over remaster date
3066be8refactor(frontend): reorganize settings and fix artist link067f534feat(subsonic): add getAlbumInfo2, getArtistInfo2, and request loggingf0c710bfeat(backend): add ntfy push notifications for Symfonium auto-sync8d260d4fix(scanner): sanitize metadata and prevent duplicate album errors51b7fa0fix(analyzer): prevent stuck tracks and improve reliability482cbbdci: only build on version tags, add auto-changelog632683eci: add GHCR workflow for easy container pulls99693f9fix: ML audio analysis and playlist diversity improvements
833ba40feat: AI Weekly improvements - sonic similarity, filtering, and UXd85bfdedocs: add examples folder with all-in-one deployment config4d5731cfeat: lazy enrichment for search, AI recs improvements, and codec display42e7d4afeat: album bio for owned albums, cover caching improvements, and CI workflowc718d27feat: album bio display, discover search timeout fix, and misc improvements
cf5c24fwebsocket: fix playback stopping on reconnect52fa608covers: fix album art loading and extend cache TTLs
ac1b7absearch: improve album search, Last.fm discovery, and UI enhancements971bb20search: improve library search ranking and Last.fm integration575abe0feat: add MBID editor, genre support, and fix temp MBID links2674b03tracks: add disc number support for multi-disc albumsbe8a79bdownloads: add interactive search for manual release selection6e0d718library: improve artist page cover loading and ownership detection
8cd27fbmusicbrainz: add retry logic with exponential backoff8a7272dfrontend: fix scan animation starting immediately on click9ef1919scanner: improve reliability, MBID handling, and UI feedback
5325e24fix: remove non-existent manualGenres/manuallyEdited fields7844740enrichment: improve album cover fetching with Deezer fallback3e69fe0playlists: add artist diversity to more generatorsb09817dfrontend: add skipped count to enrichment progress typef79fe86enrichment: add repair endpoints for album MBIDs and coversd8379d8analyzer: add 'skipped' status for oversized/timeout tracks2b64febartist: prefetch Deezer album info and improve preview indicators2490ff2fix: Recently Added now reflects actual new album additionsd7b92c6scanner: fix artist name truncation and add improvements713a6a9ui: mobile nav, library state persistence, and UX improvements
fb5ca2esecurity: harden authentication and input validation4de98bdsubsonic: add Subsonic/OpenSubsonic API for Symfonium compatibility6cf13d7docs: add Symfonium compatibility section to CLAUDE.md038c0a3docs: add Subsonic authentication setup to CLAUDE.md Expected Behavior:
- Only ONE device plays audio at any time (the "active player")
- When a remote device is selected, local playback STOPS
- All playback commands (play/pause/next/prev/seek/volume) forward to the active player
- The controller shows what's playing on the remote device
The frontend includes a custom server (frontend/server.js) that proxies WebSocket connections internally, eliminating the need for special reverse proxy configuration.
┌─────────────────────────────────────────────────────────┐
│ Docker Container │
│ │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Next.js + Proxy │ │ Express Backend │ │
│ │ (port 3030) │─────►│ (port 3006) │ │
│ │ │ WS │ - REST API │ │
│ │ /api/socket.io │proxy │ - Socket.io server │ │
│ └──────────────────┘ └──────────────────────────┘ │
│ ▲ │
└───────────│──────────────────────────────────────────────┘
│
Reverse Proxy (Traefik/nginx/Caddy)
- Only needs standard HTTP proxy to port 3030
- No special WebSocket configuration required
How it works:
frontend/server.jsuseshttp-proxy-middlewareto intercept/api/socket.iorequests- WebSocket upgrade requests are proxied to the backend on port 3006
- Users only need to expose port 3030 - no separate WebSocket routing needed
Standard reverse proxy config (example for Traefik):
labels:
- traefik.enable=true
- traefik.http.routers.lidify.rule=Host(`lidify.example.com`)
- traefik.http.services.lidify.loadbalancer.server.port=3030| File | Purpose |
|---|---|
frontend/server.js |
Custom Next.js server with WebSocket proxy to backend |
frontend/lib/remote-playback-context.tsx |
WebSocket connection, device list, activePlayerId state, isActivePlayer flag |
frontend/lib/remote-aware-audio-controls-context.tsx |
Wraps audio controls to forward commands to remote device when not active |
frontend/hooks/useRemotePlaybackIntegration.ts |
Bridges remote commands with audio controls, handles state broadcasting |
frontend/lib/audio-hooks.tsx |
Unified useAudio hook - updated to use remote-aware controls |
| File | Purpose |
|---|---|
frontend/components/player/DeviceSelector.tsx |
Dropdown UI for selecting devices |
frontend/components/player/FullPlayer.tsx |
Desktop player - shows remote control indicator, uses displayTrack |
frontend/components/player/MiniPlayer.tsx |
Compact player (mobile toast + desktop bottom bar) - uses displayTrack |
frontend/components/player/OverlayPlayer.tsx |
Fullscreen mobile player - has volume slider, uses displayTrack |
frontend/components/player/HowlerAudioElement.tsx |
Actual audio playback - has isActivePlayer guards |
frontend/components/player/RemoteVolumeCapture.tsx |
Silent audio for hardware volume button capture (experimental) |
frontend/components/providers/ConditionalAudioProvider.tsx |
Provider hierarchy |
| File | Purpose |
|---|---|
backend/src/websocket/remotePlayback.ts |
Socket.io server handling device registration, commands, state sync |
backend/src/routes/remotePlayback.ts |
REST API for device listing |
HowlerAudioElementmoved INSIDERemotePlaybackProviderso it can accessisActivePlayer
All 7 howlerEngine.play() calls in HowlerAudioElement now check isActivePlayerRef.current:
- Line 147: Repeat mode
- Line 328: Same track resume
- Line 428: Post-load autoplay
- Line 550: isPlaying effect
- Line 662: Cache polling reload
- Line 837: Seek reload fallback
- Line 848: Post-seek resume
transferPlayback() now:
- Sets
activePlayerIdFIRST (so guards see it immediately) - Stops local playback
- Waits 50ms before emitting WebSocket events
RemoteAwareAudioControlsContext wraps all controls:
- If
isActivePlayeris true → execute locally - If
isActivePlayeris false → forward via WebSocket
- Remote device broadcasts state changes via WebSocket
- Track changes broadcast IMMEDIATELY (bypass 500ms debounce)
- Controller receives updates via
playback:stateUpdateevent - Interval broadcasts every 1 second while playing (for timer sync)
activePlayerIdpersisted to localStorage (lidify_active_player_id)- Restored on page load
- Shows floating bar when controlling remote device
- Displays: device name, album art, track title, artist, play/pause status
All player UI components now use activePlayerState when controlling a remote device:
- Timer/progress: Uses
activePlayerState.currentTime - Track info: Uses
activePlayerState.currentTrack(title, artist, album art) - Duration: Uses
activePlayerState.currentTrack.duration - isPlaying: Uses
activePlayerState.isPlaying
This ensures the controller displays the remote device's actual state immediately when tracks change.
Remote playback is now fully functional:
- ✅ Commands (play, pause, next, prev, seek, volume) correctly forward to active player
- ✅ Timer syncs with the active player's current time
- ✅ Track info (title, artist, artwork) updates immediately on the controller
- ✅ State persists across page refreshes via localStorage
- ✅ Volume control syncs with remote player
- ✅ Mobile volume slider in OverlayPlayer (fullscreen player view)
The controller UI now uses activePlayerState for track info display:
FullPlayer.tsx- usesdisplayTrackderived fromactivePlayerState.currentTrackMiniPlayer.tsx- same patternOverlayPlayer.tsx- same pattern + volume slider
Added volume slider to OverlayPlayer.tsx (the fullscreen player on mobile):
- Location: Bottom of the player, after shuffle/repeat/vibe controls
- Uses local state (
localVolume) for smooth dragging without jitter - Only syncs from remote if value differs by >2% from what user set (prevents snap-back)
- Displays volume icon (tap to mute/unmute) + slider + percentage
// Smooth volume slider pattern (OverlayPlayer.tsx)
const [localVolume, setLocalVolume] = useState(displayVolume * 100);
const lastSetVolumeRef = useRef<number | null>(null);
// Only sync from remote if it's different from what we set
useEffect(() => {
const remoteVol = Math.round(displayVolume * 100);
if (lastSet === null || Math.abs(remoteVol - lastSet) > 2) {
setLocalVolume(remoteVol);
}
}, [displayVolume]);
const handleVolumeChange = (value: number) => {
setLocalVolume(value); // Immediate UI update
lastSetVolumeRef.current = value;
setVolume(value / 100); // Send to player
};RemoteVolumeCapture.tsx component attempts to capture Android hardware volume buttons when controlling a remote device by playing a silent audio element. This is hacky and may not work reliably on all devices/browsers.
When controlling a remote device, volume display uses remote state:
const displayVolume = (!isActivePlayer && activePlayerState?.volume !== undefined)
? activePlayerState.volume
: volume;[RemotePlayback] Setting activePlayerId: device_xxx
[RemotePlayback] Restored activePlayerId from storage: device_xxx
[RemoteAware] next: isActivePlayer=false, activePlayerId=device_xxx
[RemoteAware] Forwarding next to device_xxx
[RemoteIntegration] Interval broadcast: time=XX.X, playing=true
[RemotePlayback] State update received from device: device_xxx
localStorage.getItem("lidify_active_player_id")# Build (from repo directory)
cd /mnt/cache/appdata/compose/lidify/repo
docker build -t lidify-remote:latest .
# Deploy (from compose directory)
cd /mnt/cache/appdata/compose/lidify
docker compose up -d --force-recreate
# View logs
docker logs lidify --tail 100 -f
# Check BUILD_ID to verify frontend was rebuilt
docker exec lidify cat /app/frontend/.next/BUILD_ID- The Dockerfile is in
/mnt/cache/appdata/compose/lidify/repo/ - The docker-compose.yml is in
/mnt/cache/appdata/compose/lidify/ - Image tag must be
lidify-remote:latest(what compose expects) - If changes aren't appearing, check the BUILD_ID changed
- Docker layer caching can be aggressive - delete old image if needed:
docker rmi lidify-remote:latest docker build --no-cache -t lidify-remote:latest .
// frontend/lib/remote-playback-context.tsx:177
const isActivePlayer = activePlayerId === null || activePlayerId === currentDeviceId;// frontend/lib/remote-aware-audio-controls-context.tsx:121-146
const executeOrForward = useCallback((command, localAction, payload) => {
if (isActivePlayer) {
localAction(); // Execute locally
} else if (activePlayerId) {
sendCommand(activePlayerId, command, payload); // Forward to remote
} else {
localAction(); // Fallback to local
}
}, [isActivePlayer, activePlayerId, sendCommand]);// frontend/components/player/HowlerAudioElement.tsx:527-537
if (isPlaying) {
if (!isActivePlayer) {
console.log("[HowlerAudioElement] Blocking local play - not active player");
return;
}
howlerEngine.play();
}// Used in FullPlayer.tsx, MiniPlayer.tsx, OverlayPlayer.tsx
const displayTrack = (!isActivePlayer && activePlayerState?.currentTrack)
? activePlayerState.currentTrack
: currentTrack;
// Remote track has different shape than local Track type:
// - artist: string (not object)
// - coverArt: string (not album.coverArt)
const album = (displayTrack as any).album;
const coverArt = (album && typeof album === 'object' && album.coverArt)
? album.coverArt
: (displayTrack as any).coverArt;// Used in MiniPlayer.tsx, OverlayPlayer.tsx
const displayVolume = (!isActivePlayer && activePlayerState?.volume !== undefined)
? activePlayerState.volume
: volume;Several improvements were made to the Lidarr integration to fix download reliability and automatic library sync issues.
Files Modified:
backend/prisma/schema.prisma- AddedlidarrQualityProfileIdfield to SystemSettingsbackend/src/routes/systemSettings.ts- Added/lidarr-quality-profilesendpoint (before auth middleware)backend/src/services/lidarr.ts- Updated to use configured quality profile instead of hardcoded1frontend/features/settings/types.ts- AddedlidarrQualityProfileIdto SystemSettings typefrontend/features/settings/hooks/useSystemSettings.ts- Added default valuefrontend/features/settings/components/sections/LidarrSection.tsx- Added quality profile dropdown
What it does:
- Allows selecting which Lidarr quality profile to use for downloads (e.g., "Lossless" instead of "Any")
- Dropdown fetches available profiles from Lidarr API
- Profile selection persists in database
File Modified: backend/src/services/lidarr.ts
Added:
async hasActiveDownloads(lidarrArtistId: number): Promise<boolean>What it does:
- Before deleting an artist, checks if they have active downloads in Lidarr's queue
- Prevents orphaned downloads (downloads that can't be imported because the artist was deleted)
- Both
deleteArtist()anddeleteArtistById()now check before deleting
File Modified: backend/src/services/lidarr.ts (in addArtist())
What it does:
- When adding a new artist to Lidarr, waits up to 30 seconds for metadata refresh to complete
- Polls Lidarr command status every 2 seconds
- Prevents "album not found" errors when downloading from newly added artists
- Previously, the metadata refresh was fire-and-forget, causing race conditions
File Modified: backend/src/services/lidarr.ts (in addAlbum())
What it does:
- When looking up an artist in Lidarr, first tries MBID match
- If MBID doesn't match, falls back to case-insensitive name match
- Prevents duplicate artist creation when MBIDs differ between Lidify and Lidarr
// Fallback: try to find by name if MBID didn't match
if (!artist && artistName) {
const normalizedName = artistName.toLowerCase().trim();
artist = existingArtists.data.find(
(a: LidarrArtist) =>
a.artistName.toLowerCase().trim() === normalizedName
);
}For automatic library sync after Lidarr imports, a webhook must be configured:
In Lidarr → Settings → Connect → Add → Webhook:
| Field | Value |
|---|---|
| Name | Lidify |
| URL | http://host.docker.internal:3030/api/webhooks/lidarr |
| Method | POST |
| On Grab | ✅ |
| On Release Import | ✅ |
| On Download Failure | ✅ |
| On Import Failure | ✅ |
Important: Lidarr's docker-compose needs extra_hosts for the webhook to work on Linux:
services:
lidarr:
# ... other config ...
extra_hosts:
- "host.docker.internal:host-gateway"After updating, run this SQL to add the quality profile column:
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "lidarrQualityProfileId" INTEGER;Or let Prisma handle it on container startup.
# Check webhook events
docker logs lidify 2>&1 | grep "WEBHOOK"
# Check download flow
docker logs lidify 2>&1 | grep -i "download\|lidarr\|artist"
# Check Lidarr queue
docker logs lidarr 2>&1 | grep -i "download\|grab\|import"
# Verify webhook config in Lidarr
docker exec lidarr curl -s "http://localhost:8686/api/v1/notification" \
-H "X-Api-Key: YOUR_API_KEY" | jq '.[].name'File Modified: backend/src/services/simpleDownloadManager.ts (in startDownload())
What it does:
- Before trusting an artist MBID from MusicBrainz, validates the artist name matches
- Prevents downloading wrong artist when MusicBrainz album data has incorrect artist credits
- If names don't match, falls back to name-based matching in Lidarr
// Validate artist name matches before trusting MBID
const requestedNorm = artistName.toLowerCase().trim();
const mbNorm = mbArtistName.toLowerCase().trim();
if (mbNorm === requestedNorm || mbNorm.includes(requestedNorm) || requestedNorm.includes(mbNorm)) {
artistMbid = mbArtistId;
} else {
console.warn(` Artist name mismatch - ignoring MBID`);
// Will use name-based matching instead
}Bug this fixes: Discovery downloads could add the wrong artist (e.g., "Robert Schumann" instead of "Robert Taylor") when MusicBrainz album data had incorrect artist credits.
- Discovery cache can hold stale temp MBIDs - If an artist shows a temp ID, try hard refresh or wait for cache to expire
- First download attempt may fail - If MBID lookup or indexer search times out, retry usually works
The audio analyzer would get stuck processing large hi-res FLAC files (24-bit/96kHz+, 100-600MB), causing:
- Batch timeouts (5-minute limit exceeded)
- All tracks in the batch stuck in "processing" status
- Infinite retry loops as failed tracks reset on container restart
Files Modified: services/audio-analyzer/analyzer.py
MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', '100'))- Files exceeding limit are skipped before processing
- Marked as permanently failed (won't retry)
Tracks are marked as permanent failures (retry count = MAX_RETRIES) for:
- Oversized files
- Timeout errors
- Memory errors
def _save_failed(self, track_id: str, error: str, permanent: bool = False):
if permanent:
# Set retry count to MAX_RETRIES immediatelyBASE_TRACK_TIMEOUT = int(os.getenv('BASE_TRACK_TIMEOUT', '120'))
MAX_TRACK_TIMEOUT = int(os.getenv('MAX_TRACK_TIMEOUT', '600'))environment:
- MAX_FILE_SIZE_MB=100 # Skip files larger than this (0 = disabled)
- BASE_TRACK_TIMEOUT=120 # Base timeout per track in seconds
- MAX_TRACK_TIMEOUT=600 # Maximum timeout even for large files⊘ Skipped: file.flac - File too large (123.7MB > 100MB limit)
⊘ Timeout (permanent): file.flac
✓ Completed (67.7MB): file.flac
The "Enhanced mode" ML mood analysis was never actually working. All 32K+ tracks were analyzed with "Standard mode" heuristics instead of real ML predictions. This caused:
- Playlist generators falling back to unreliable heuristics
- No
moodHappy,moodSad,moodRelaxed,moodAggressivevalues in database - "Chill Mix" containing death metal, "Happy Vibes" containing black metal
Dockerfile downloaded Discogs EfficientNet models:
discogs-effnet-bs64-1.pb
mood_happy-discogs-effnet-1.pb
Analyzer code looked for MusiCNN models:
'musicnn': os.path.join(MODEL_DIR, 'msd-musicnn-1.pb'), # DOESN'T EXIST
'mood_happy': os.path.join(MODEL_DIR, 'mood_happy-msd-musicnn-1.pb'), # DOESN'T EXISTResult: Model loading silently failed, fell back to Standard mode for ALL tracks.
The Discogs EfficientNet models have inconsistent column ordering per model:
| Model | Column 0 | Column 1 | Positive Class |
|---|---|---|---|
| mood_aggressive | aggressive | not_aggressive | Column 0 |
| mood_happy | happy | non_happy | Column 0 |
| mood_sad | non_sad | sad | Column 1 |
| mood_relaxed | non_relaxed | relaxed | Column 1 |
| danceability | danceable | not_danceable | Column 0 |
| voice_instrumental | instrumental | voice | Column 1 |
Original code assumed column 1 was always positive:
positive_probs = preds[:, 1] # WRONG for half the modelsFile Modified: services/audio-analyzer/analyzer.py
MODELS = {
'effnet': os.path.join(MODEL_DIR, 'discogs-effnet-bs64-1.pb'),
'mood_happy': os.path.join(MODEL_DIR, 'mood_happy-discogs-effnet-1.pb'),
# ... etc
}from essentia.standard import TensorflowPredictEffnetDiscogs
self.effnet_model = TensorflowPredictEffnetDiscogs(
graphFilename=MODELS['effnet'],
output="PartitionedCall:1"
)# Column order verified from model metadata JSON files at essentia.upf.edu
positive_col = 0 if model_name in ['mood_aggressive', 'mood_happy', 'danceability'] else 1
positive_probs = preds[:, positive_col]After fix, metal tracks correctly classified:
Manowar - Thor (The Powerhead):
mood_aggressive: 0.964 (was 0.036 before fix)
mood_happy: 0.279
mood_relaxed: 0.034
All existing tracks need re-analysis to populate ML mood fields:
UPDATE "Track" SET "analysisStatus" = 'pending', "analysisMode" = NULL
WHERE "analysisMode" = 'standard';- Silent fallback: Standard mode ran without obvious errors
- Plausible outputs: Heuristic valence/arousal values looked reasonable
- Buried error logs: "Base MusiCNN model not found" error lost in startup noise
- Working playlists: Generators fell back to Standard mode queries
"Made for You" playlists were dominated by artists imported early (e.g., 90% AC/DC). Root causes:
- Prisma queries without
orderByreturn by insertion order (primary key) take: 200limits pool to first 200 matching tracks- No artist diversity enforcement
Files Modified:
backend/src/services/programmaticPlaylists.tsbackend/src/routes/library.ts
function diversifyByArtist<T extends { album?: { artist?: { id?: string } } }>(
tracks: T[],
maxPerArtist: number = 2
): T[] {
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
const artistCounts = new Map<string, number>();
const diverse: T[] = [];
for (const track of shuffled) {
const artistId = track.album?.artist?.id || `unknown-${Math.random()}`;
const count = artistCounts.get(artistId) || 0;
if (count < maxPerArtist) {
diverse.push(track);
artistCounts.set(artistId, count + 1);
}
}
return diverse.sort(() => Math.random() - 0.5);
}All fixed generators now:
- Include artist ID:
include: { album: { select: { coverUrl: true, artist: { select: { id: true } } } } } - Remove
takeandorderBy- fetch ALL matching tracks from entire library - Apply diversity:
diversifyByArtist(tracks, 2)- shuffles randomly + limits per artist
Why no take limit?
Using take: 300 with orderBy: { id: 'desc' } just swaps "always oldest" for "always newest" - still biased. Fetching all matching tracks (e.g., 32K unplayed) and randomly sampling gives true variety.
// Before (biased to newest 300):
const tracks = await prisma.track.findMany({
where: { ... },
take: 300,
orderBy: { id: 'desc' },
});
// After (entire library pool):
const tracks = await prisma.track.findMany({
where: { ... },
// No take, no orderBy
});
const diverse = diversifyByArtist(tracks, 2); // Shuffles + limits per artist| Generator | Status |
|---|---|
generateDeepCuts |
✅ Fixed |
generateChillMix |
✅ Fixed |
generateWorkoutMix |
✅ Fixed |
generateHighEnergyMix |
✅ Fixed |
generateLateNightMix |
✅ Fixed |
generatePartyMix |
⏳ Pending (complex - uses Genre table) |
generateFocusMix |
⏳ Pending |
generateHappyMix |
⏳ Pending |
generateMelancholyMix |
⏳ Pending |
| Others... | ⏳ Pending |
Also fixed diversifyTracksByArtist helper added for workout radio and other radio modes.
After deploying changes, clear Redis cache to regenerate playlists:
docker exec lidify /usr/bin/redis-cli FLUSHALLDisabled - The Last.fm mood tag enrichment was found to have only ~1% hit rate (most tracks return "no tags"). The Essentia-generated mood tags from audio analysis provide much better coverage (14K+ tracks tagged as groovy, dance, moody, etc.).
AI-powered artist recommendations using OpenRouter (multi-provider LLM gateway), with conversational refinement.
Migrated from OpenAI direct to OpenRouter for access to 200+ models from multiple providers (OpenAI, Anthropic, Google, Meta, DeepSeek, etc.).
Files Changed:
backend/src/services/openai.ts→backend/src/services/openrouter.ts- Renamed and updatedbackend/src/config.ts-OPENAI_API_KEY→OPENROUTER_API_KEYbackend/prisma/schema.prisma-openaiEnabled/openaiModel→openrouterEnabled/openrouterModeldocker-compose.yml- Environment variable renamed- All frontend references updated
Database Migration:
-- Migration: 20251231000000_rename_openai_to_openrouter
ALTER TABLE "SystemSettings" ADD COLUMN "openrouterEnabled" BOOLEAN DEFAULT false;
ALTER TABLE "SystemSettings" ADD COLUMN "openrouterModel" TEXT DEFAULT 'openai/gpt-4o-mini';
-- Copies data from old columns, then drops themCore:
- Button on artist page opens slide-over panel
- 6-8 AI-generated similar artist recommendations
- Shows artist photo (from Deezer), reason, recommended album
- "In Library" badge for artists already in collection
- Click recommendation → searches for artist (uses search to get proper MBID)
Conversational Refinement:
- Chat input for follow-up requests ("more electronic", "female vocalists")
- Conversation history with message bubbles
- Redis stores conversation (1hr TTL)
- SessionStorage caches conversation per artist (survives page refresh)
UI Enhancements:
- Regenerate button - Refresh icon in header clears cache and fetches fresh recommendations
- Model label - Small monospace text below header shows which model served the results (e.g.,
deepseek/deepseek-chat-v3-0324) - Searchable model dropdown - Settings page has searchable dropdown with all 200+ OpenRouter models
OpenRouter must be enabled in Settings → AI & Enhancement Services:
- Enable OpenRouter toggle (grayed out if API key not configured)
- Select model from searchable dropdown (GPT-4o Mini, DeepSeek, Claude, Gemini, etc.)
Environment Variable:
# In docker-compose.yml or .env
OPENROUTER_API_KEY=sk-or-v1-...Get your API key at: https://openrouter.ai/keys
| Model | Cost/rec | Notes |
|---|---|---|
deepseek/deepseek-chat-v3-0324 |
~$0.0004 | Best value, excellent quality |
google/gemini-2.0-flash-001 |
~$0.002 | Fast, good knowledge |
openai/gpt-4o-mini |
~$0.001 | Reliable, good JSON formatting |
anthropic/claude-3-haiku |
~$0.001 | Fast reasoning |
Artist ID handling for recommendations:
// ArtistActionBar passes fallback chain
artistId={artist.id || artist.mbid || artist.name}Discovery artists navigate to search:
// Non-library recommendations go through search for proper MBID
router.push(`/search?q=${encodeURIComponent(artist.artistName)}`);Mobile keyboard fix:
// Only auto-focus input on desktop to avoid keyboard popup on mobile
if (isOpen && messages.length > 0 && window.innerWidth >= 768) {
inputRef.current?.focus();
}| Endpoint | Method | Description |
|---|---|---|
/api/system-settings/openrouter-status |
GET | Check if API key is configured |
/api/system-settings/openrouter-models |
GET | Fetch available models from OpenRouter |
/api/system-settings/test-openrouter |
POST | Test connection with selected model |
/api/artists/ai-chat/:artistId |
POST | Get AI recommendations (returns model in response) |
Problem: Completed downloads were being deleted before import.
Cause: clearLidarrQueue() in simpleDownloadManager.ts treated importPending as a failure state. Downloads at 100% waiting for import were removed.
Fix: Removed importPending from failure conditions:
// Before (WRONG - deleted completed downloads)
item.trackedDownloadState === "importPending" ||
// After (correct)
// Only: failed, error, importFailed, or warnings with messagesProblem: Recently added artists got URLs like /artist/temp-1767141916598-... which led nowhere.
Cause: Frontend used artist.mbid || artist.id - temp MBIDs are truthy so they were used instead of database ID.
Fix: Changed all artist links to use artist.id (CUID) instead:
// Before
href={`/artist/${artist.mbid || artist.id}`}
// After
href={`/artist/${artist.id}`}Files fixed:
frontend/features/library/components/ArtistsGrid.tsxfrontend/features/home/components/ArtistsGrid.tsxfrontend/app/artists/page.tsxfrontend/features/search/components/LibraryTracksList.tsxfrontend/features/home/components/ContinueListening.tsxfrontend/components/player/OverlayPlayer.tsxfrontend/components/player/FullPlayer.tsxfrontend/features/album/components/AlbumHero.tsx
Enhancement: Show album name instead of artist name (redundant on artist page).
Additional: When playing Deezer preview, album info from Deezer is displayed:
backend/src/services/deezer.ts- AddedgetTrackPreviewWithInfo()returning album title/coverbackend/src/routes/artists.ts- Preview endpoint returns album infofrontend/features/artist/hooks/usePreviewPlayer.ts- Stores album info per trackfrontend/features/artist/components/PopularTracks.tsx- Shows Deezer album after preview
Fixed two major issues preventing Symfonium (Android music app) from working correctly with Lidify's Subsonic API, while Ultrasonic worked fine with the same server.
Symptom: Sorting albums by "Last Played" in Symfonium produced alphabetical order instead of actual play history. "Recently Added" worked correctly.
Root Cause Investigation:
Analyzing Symfonium's debug logs revealed:
UPDATE albums SET last_played = (SELECT MAX(songs.last_played) FROM songs WHERE songs.album_id = albums._id)Symfonium derives albums.last_played from MAX(songs.last_played) - it doesn't read the album's played field directly. The server was sending played on albums but NOT on songs.
The Fix:
Added played and playCount fields to all song responses.
Files Modified:
backend/src/utils/subsonicResponse.ts-formatTrackForSubsonic()now accepts optionalplayDataparameterbackend/src/routes/subsonic.ts- All song endpoints now query Play table and pass data:search3.view- Batch queries play data for all songsgetAlbum.view- Queries play data for album tracksgetSong.view- Queries play data for single trackgetRandomSongs.view- Queries play data for random tracksgetPlaylist.view- Queries play data for playlist tracksgetTopSongs.view- Queries play data for top songs
Code Pattern:
// Query play data for songs
const songIds = songs.map(s => s.id);
const songPlayData = songIds.length > 0 ? await prisma.$queryRaw<Array<{ trackId: string; lastPlayed: Date; playCount: bigint }>>`
SELECT p."trackId", MAX(p."playedAt") as "lastPlayed", COUNT(p.id) as "playCount"
FROM "Play" p
WHERE p."trackId" = ANY(${songIds})
GROUP BY p."trackId"
` : [];
const songLastPlayed = new Map(songPlayData.map(d => [d.trackId, d.lastPlayed]));
const songPlayCount = new Map(songPlayData.map(d => [d.trackId, Number(d.playCount)]));
// Pass to formatter
formatTrackForSubsonic(track, {
played: songLastPlayed.get(track.id),
playCount: songPlayCount.get(track.id) || 0,
})Symptom: Artwork visible in Lidify web but missing in Symfonium.
Root Cause: getCoverArt.view was doing res.redirect(imageUrl) to external URLs. Many Subsonic clients don't handle redirects well. Additionally, local covers stored as native:xxx.jpg weren't being resolved.
The Fix:
- Handle
native:covers - Serve local files directly from/covers/directory - Proxy external URLs - Fetch and pipe through instead of redirecting
- Cache external artwork - Save to disk on first request, serve from cache thereafter
File Modified: backend/src/routes/subsonic.ts - getCoverArt.view endpoint
Implementation:
// Handle native (local) cover files
if (imageUrl.startsWith("native:")) {
const nativePath = imageUrl.replace("native:", "");
const coverCachePath = path.join(coverCacheDir, nativePath);
if (fs.existsSync(coverCachePath)) {
res.set('Content-Type', 'image/jpeg');
return fs.createReadStream(coverCachePath).pipe(res);
}
}
// For external URLs, check cache first
const cacheFileName = `ext-${entityId}.jpg`;
const cachedFilePath = path.join(coverCacheDir, cacheFileName);
if (fs.existsSync(cachedFilePath)) {
const stats = fs.statSync(cachedFilePath);
const ageMs = Date.now() - stats.mtimeMs;
if (ageMs < 7 * 24 * 60 * 60 * 1000) { // 7 days
return fs.createReadStream(cachedFilePath).pipe(res);
}
}
// Download, cache, and serve
const imageResponse = await axios.get(imageUrl, { responseType: 'arraybuffer' });
fs.writeFileSync(cachedFilePath, imageResponse.data);
res.send(imageResponse.data);Cache Behavior:
- Native covers: Already local, served directly (fast)
- External URLs: Cached to
/covers/ext-{entityId}.jpgfor 7 days - First sync is slow (downloads all artwork), subsequent access is instant
Check what endpoints Symfonium calls:
grep -o 'rest/[a-zA-Z0-9]*\.view' debug.log | sort | uniq -c | sort -rnVerify song responses include played:
docker logs lidify 2>&1 | grep -A2 "search3"
# Look for: "played":"2026-01-01T..." in song objectsCheck cover art caching:
docker exec lidify ls -la /app/cache/covers/ | head -20When Client A works and Client B doesn't with the same API, check how each client stores and uses the data, not just whether you're sending it. Symfonium's internal SQLite schema revealed it derives album metadata from songs, which wasn't obvious from the API spec alone.
Setting the password:
- Go to Settings → Subsonic in Lidify web UI
- Enter a password and click Save
- Password is stored AES-256 encrypted (not plaintext)
Connecting from Symfonium/Feishin:
- Server URL:
https://lidify.example.com/rest(or with/restappended) - Username: Your Lidify username
- Password: The Subsonic password you set (not your Lidify login password)
- Auth method: Token auth (modern/secure) - disable "Legacy auth" if prompted
Supported auth methods:
- Token auth (recommended):
t=md5(password+salt)&s=salt - Plain password:
p=password(verified against bcrypt hash)
Files:
backend/src/middleware/subsonicAuth.ts- Auth middlewarebackend/src/routes/auth.ts-/auth/subsonic-passwordendpoints (GET/POST/DELETE)frontend/features/settings/components/sections/APIKeysSection.tsx- Settings UI
Current: Playback position resets to start on page reload.
TODO: Add localStorage persistence:
- Save
currentTimeon time updates (throttled) - Restore position when track loads
- Clear on track change/completion
Symptom: "Made for You" playlists were 90% one artist (e.g., AC/DC).
Root Cause: Prisma queries without orderBy return rows by insertion order (primary key). Using take: 50 always returned the same 50 earliest-imported tracks.
Solution: Applied to 26 playlist generators in backend/src/services/programmaticPlaylists.ts:
// Before (biased):
const tracks = await prisma.track.findMany({
where: { ... },
take: 50, // Always same 50 tracks
});
// After (diverse):
const tracks = await prisma.track.findMany({
where: { ... },
include: { album: { select: { coverUrl: true, artist: { select: { id: true } } } } },
// No take - fetch ALL matching
});
const diverseTracks = diversifyByArtist(tracks, 2); // Max 2 per artist, shuffleddiversifyByArtist helper (line 120):
- Shuffles tracks randomly
- Limits to N tracks per artist
- Returns diverse selection
Fixed Generators: All Gen Z vibe mixes (14), audio analysis mixes (4), day-specific mixes (3), technical mixes (4), plus PartyMix.
Symptom: Album art missing for most albums, including ones available on Last.fm/Deezer.
Root Cause Chain:
- Music scanner assigns temp MBIDs (
temp-1234...) when files lack embeddedmusicbrainz_releasegroupid enrichAlbumCoverstried querying Cover Art Archive with temp MBIDs → 404- CAA failures cached as "NOT_FOUND" for 7 days
- No fallback to other sources (Deezer, Fanart.tv)
Stats before fix:
- 2,736 / 2,772 albums (98.7%) had temp MBIDs
- 509 albums had no cover art
File: backend/src/workers/artistEnrichment.ts
Change: Modified enrichAlbumCovers() to:
- Skip Cover Art Archive for temp MBIDs
- Use
imageProviderService.getAlbumCover()as fallback (Deezer → Fanart → Last.fm)
// Key change in enrichAlbumCovers():
const hasValidMbid = album.rgMbid && !album.rgMbid.startsWith("temp-");
// Strategy 1: CAA for valid MBIDs only
if (hasValidMbid) {
coverUrl = await coverArtService.getCoverArt(album.rgMbid);
}
// Strategy 2: Deezer fallback (works without MBID)
if (!coverUrl) {
const result = await imageProviderService.getAlbumCover(
album.artist.name,
album.title,
hasValidMbid ? album.rgMbid : undefined
);
if (result) coverUrl = result.url;
}File: backend/src/routes/enrichment.ts
Added three endpoints:
| Endpoint | Method | Description |
|---|---|---|
/api/enrichment/repair/status |
GET | Count albums needing repair |
/api/enrichment/repair/album-mbids |
POST | Resolve temp MBIDs via MusicBrainz |
/api/enrichment/repair/album-covers |
POST | Fetch missing covers via Deezer |
MBID Repair Logic:
- Find albums with temp MBIDs where artist has valid MBID
- Query MusicBrainz for artist's discography
- Match album by title (normalized, case-insensitive)
- Update album with correct
rgMbid
File: backend/src/services/musicScanner.ts
Change: When creating new albums without embedded MBID, query MusicBrainz first:
// Added import:
import { musicBrainzService } from "./musicbrainz";
// In album creation logic (around line 629):
if (!rgMbid && artist.mbid && !artist.mbid.startsWith("temp-")) {
try {
const releaseGroups = await musicBrainzService.getReleaseGroups(
artist.mbid,
["album", "ep", "single"],
100
);
const normalizedAlbumTitle = albumTitle.toLowerCase().replace(/[^a-z0-9]/g, "");
const match = releaseGroups.find((rg: any) => {
const rgNormalized = rg.title.toLowerCase().replace(/[^a-z0-9]/g, "");
return rgNormalized === normalizedAlbumTitle;
});
if (match) {
rgMbid = match.id;
console.log(`[Scanner] Found MBID for "${albumTitle}" via MusicBrainz`);
}
} catch (err) {
// Fall back to temp MBID
}
}
if (!rgMbid) {
rgMbid = `temp-${Date.now()}-${Math.random()}`;
}Repair album covers via Deezer (shell script):
docker exec lidify psql -U lidify -d lidify -t -A -c "
SELECT al.id, a.name, al.title FROM \"Album\" al
JOIN \"Artist\" a ON al.\"artistId\" = a.id
WHERE al.\"coverUrl\" IS NULL OR al.\"coverUrl\" = '';" | \
while IFS='|' read -r id artist album; do
query=$(echo "$artist $album" | sed 's/ /%20/g')
cover=$(curl -s "https://api.deezer.com/search/album?q=$query&limit=1" | jq -r '.data[0].cover_xl // empty')
if [ -n "$cover" ]; then
docker exec lidify psql -U lidify -d lidify -c "UPDATE \"Album\" SET \"coverUrl\" = '$cover' WHERE id = '$id';"
echo "✓ $artist - $album"
fi
sleep 0.3
doneRepair MBIDs via MusicBrainz (runs ~45 min for large libraries):
# Get artists with temp album MBIDs
docker exec lidify psql -U lidify -d lidify -t -A -c "
SELECT DISTINCT a.mbid, a.name FROM \"Artist\" a
JOIN \"Album\" al ON al.\"artistId\" = a.id
WHERE al.\"rgMbid\" LIKE 'temp-%' AND a.mbid NOT LIKE 'temp-%';" | \
while IFS='|' read -r mbid name; do
# Query MusicBrainz for discography
rgs=$(curl -s "https://musicbrainz.org/ws/2/release-group?artist=$mbid&type=album|ep&limit=100&fmt=json" \
-H "User-Agent: Lidify/1.0")
# Match and update albums...
sleep 1.1 # MusicBrainz rate limit
done| File | Changes |
|---|---|
backend/src/services/programmaticPlaylists.ts |
Added diversifyByArtist(), fixed 26 generators |
backend/src/workers/artistEnrichment.ts |
Added Deezer fallback for album covers |
backend/src/routes/enrichment.ts |
Added repair endpoints |
backend/src/services/musicScanner.ts |
Added MusicBrainz lookup for new albums |
# Clear Redis to regenerate playlists
docker exec lidify /usr/bin/redis-cli FLUSHALL
# Check repair status
docker exec lidify psql -U lidify -d lidify -c "
SELECT
COUNT(*) FILTER (WHERE \"rgMbid\" LIKE 'temp-%') as temp_mbid,
COUNT(*) FILTER (WHERE \"coverUrl\" IS NULL OR \"coverUrl\" = '') as no_cover,
COUNT(*) as total
FROM \"Album\";"