Skip to content

Commit b6abfd0

Browse files
author
Your Name
committed
v1.4.1: bug fixes, enrichment resilience, frontend lint cleanup
Audio & Playback: - Fix doubled audio on track change (destroy old Howl before creating new) - Fix play/pause visual desync by using Howler state as source of truth Soulseek & Downloads: - Fix Soulseek download 400 error (closes #101) - Expand Soulseek documentation (closes #27) Enrichment & Analysis: - Fix CLAP analyzer clobbering Essentia analysisStatus (root cause of #79) - Add embedding check to both Python analyzers before resetting tracks - Set analysisStartedAt when marking tracks as processing - Clear analysisStartedAt on successful completion - Include vibe embeddings in isFullyComplete check - Add keepPreviousData to enrichment progress query for UI resilience - Shut down idle worker pool to free ~5.6 GB when no work pending - Fix compilation matching for multi-disc albums (closes #70) - Add podcast auto-refresh in enrichment cycle (closes #81) Admin & Auth: - Add admin password reset via env var (closes #97) - Add retry failed analysis button in settings (closes #79) - Add requireAdmin to onboarding config and cleanup routes - Remove userId from 2FA challenge response Queue & UI: - Fix cancelJob/refreshJobMatches not persisting state - Fix discovery polling leak on batch failure - Fix withTimeout timer leak in enrichment worker - Fix useAlbumData infinite re-render loop - Fix unhandled audio.play() promise rejection Performance: - Eliminate N+1 queries in recommendation endpoints - Idle Essentia worker pool shutdown (8 processes, ~5.6 GB freed) Frontend Quality: - Fix all 377 ESLint errors and warnings (0 remaining) - Fix Rules of Hooks, setState-in-effect, exhaustive-deps - Type api.ts and 50+ files (remove explicit any) - Remove 1664 lines of dead code and duplicates - Extract shared utilities from duplicated patterns
1 parent f2a443c commit b6abfd0

File tree

148 files changed

+1683
-3245
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

148 files changed

+1683
-3245
lines changed

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,52 @@ All notable changes to Lidify will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.4.1] - 2026-02-06
9+
10+
### Fixed
11+
12+
- **Doubled audio stream on next-track:** Fixed race condition where clicking next/previous played two streams simultaneously by making track-change cleanup synchronous and guarding the play/pause effect during loading
13+
- **Soulseek download returns 400 (#101):** Frontend now sends parsed title to the download endpoint; backend derives artist/title from filename when not provided instead of rejecting the request
14+
- **Admin password reset (#97):** Added `ADMIN_RESET_PASSWORD` environment variable support -- set it and restart to reset the admin password, then remove the variable
15+
- **Retry failed audio analysis UI (#79):** Added "Retry Failed Analysis" button in Settings that resets permanently failed tracks back to pending for re-processing
16+
- **Podcast auto-refresh (#81):** Podcasts now automatically refresh during the enrichment cycle (hourly), checking RSS feeds for new episodes without manual intervention
17+
- **Compilation track matching (#70):** Added title-only fallback matching strategy for playlist reconciliation -- when album artist doesn't match (e.g. "Various Artists" compilations), tracks are matched by title with artist similarity scoring
18+
- **Soulseek documentation (#27):** Expanded README with detailed Soulseek integration documentation covering setup, search, download workflow, and limitations
19+
- **Admin route hardening:** Added `requireAdmin` middleware to onboarding config routes and stale job cleanup endpoint
20+
- **2FA userId leak:** Removed userId from 2FA challenge response (information disclosure)
21+
- **Queue bugs:** Fixed cancelJob/refreshJobMatches not persisting state, clear button was no-op, reorder not restarting track, shuffle indices not updating on removeFromQueue
22+
- **Infinite re-render:** Fixed useAlbumData error handling causing infinite re-render loop
23+
- **2FA status not loading:** Fixed AccountSection not loading 2FA status on mount
24+
- **Password change error key mismatch:** Fixed error key mismatch in AccountSection password change handler
25+
- **Discovery polling leak:** Fixed polling never stopping on batch failure
26+
- **Timer leak:** Fixed withTimeout not clearing timer in enrichment worker
27+
- **Audio play rejection:** Fixed unhandled promise rejection on audio.play()
28+
- **Library tab validation:** Added tab parameter validation in library page
29+
- **Onboarding state:** Separated success/error state in onboarding page
30+
- **Audio analysis race condition (#79):** CLAP analyzer was clobbering Essentia's `analysisStatus` field, causing completed tracks to be reset and permanently failed after 3 cycles; both Python analyzers now check for existing embeddings before resetting
31+
- **Enrichment completion check:** `isFullyComplete` now includes CLAP vibe embeddings, not just audio analysis
32+
- **Enrichment UI resilience:** Added `keepPreviousData` and loading/error states to enrichment progress query so the settings block doesn't vanish on failed refetch
33+
34+
### Performance
35+
36+
- **Recommendation N+1 queries:** Eliminated N+1 queries in all 3 recommendation endpoints (60+ queries down to 3-5)
37+
- **Idle worker pool shutdown:** Essentia analyzer shuts down its 8-worker process pool (~5.6 GB) after idle period, lazily restarts when work arrives
38+
39+
### Changed
40+
41+
- **Shared utility consolidation:** Replaced 10 inline `formatDuration` copies with shared `formatTime`/`formatDuration`, extracted `formatNumber` to shared utility, consolidated inline Fisher-Yates shuffle with shared `shuffleArray`
42+
- **Player hook extraction:** Extracted shared `useMediaInfo` hook, eliminating ~120 lines of duplicated media info logic across MiniPlayer, FullPlayer, and OverlayPlayer
43+
- **Preview hook consolidation:** Consolidated artist/album preview hooks into shared `useTrackPreview`
44+
- **Redundant logging cleanup:** Removed console.error calls redundant with toast notifications or re-thrown errors
45+
46+
### Removed
47+
48+
- Dead player files: VibeOverlay, VibeGraph, VibeOverlayContainer, enhanced-vibe-test page
49+
- Dead code: trackEnrichment.ts, discover/types/index.ts, unused artist barrel file
50+
- Unused exports: `playTrack` from useLibraryActions, `useTrackDisplayData`/`TrackDisplayData` from useMetadataDisplay
51+
- Unused `streamLimiter` middleware
52+
- Deprecated `radiosByGenre` from browse API (Deezer radio requires account; internal library radio used instead)
53+
854
## [1.4.0] - 2026-02-05
955

1056
### Performance

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -515,16 +515,39 @@ Connect to your Audiobookshelf instance to browse and listen to audiobooks withi
515515

516516
### Soulseek
517517

518-
For finding rare tracks and one-offs that aren't available through traditional sources, Lidify has built-in Soulseek support.
518+
Lidify includes built-in Soulseek support for finding rare tracks and one-offs that aren't available through traditional download sources like Lidarr.
519+
520+
[Soulseek](https://www.slsknet.org/) is a peer-to-peer file sharing network focused on music. Users share their music libraries and can browse/download from each other. Lidify connects directly to the Soulseek network -- no additional software (like slskd) is required.
519521

520522
**Setup:**
521523

522524
1. Go to Settings in Lidify
523525
2. Navigate to the Soulseek section
524-
3. Enter your Soulseek username and password
526+
3. Enter your Soulseek username and password (create an account at [slsknet.org](https://www.slsknet.org/) if you don't have one)
525527
4. Save your settings
526528

527-
Lidify connects directly to the Soulseek network - no additional software required.
529+
**How Search Works:**
530+
531+
When you search for music in Lidify's Discovery tab, Soulseek results appear alongside Last.fm and Deezer results. Each result shows the filename, file size, bitrate, and format (FLAC/MP3). Metadata like artist and album is parsed from the file path structure (typically `Artist/Album/01 - Track.flac`).
532+
533+
**How Download Works:**
534+
535+
1. Click the download button on a Soulseek search result
536+
2. Lidify searches the Soulseek network for the best match (preferring FLAC, high bitrate)
537+
3. The file is downloaded directly to your music library path
538+
4. A library scan is triggered to import the new file
539+
5. Metadata enrichment runs automatically (artist info, mood tags, audio analysis)
540+
541+
You can also configure Soulseek as a download source for playlist imports. In Settings > Downloads, set Soulseek as primary or fallback source. When importing a Spotify/Deezer playlist, tracks not found in your library will be searched and downloaded from Soulseek automatically.
542+
543+
**Download progress** is visible in the Activity Panel (bell icon in the top bar).
544+
545+
**Limitations:**
546+
547+
- Download speed depends on the sharing user's connection and availability
548+
- Not all tracks will have results -- Soulseek coverage varies by genre and popularity
549+
- Some users may have slow connections or go offline during transfers
550+
- Lidify retries with alternative users if a download fails or times out
528551

529552
---
530553

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "lidify-backend",
3-
"version": "1.4.0",
3+
"version": "1.4.1",
44
"description": "Lidify backend API server",
55
"license": "GPL-3.0",
66
"repository": {

backend/src/index.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import { requireAuth, requireAdmin } from "./middleware/auth";
4545
import {
4646
authLimiter,
4747
apiLimiter,
48-
streamLimiter,
4948
imageLimiter,
5049
} from "./middleware/rateLimiter";
5150
import swaggerUi from "swagger-ui-express";
@@ -246,11 +245,33 @@ async function checkRedisConnection() {
246245
}
247246
}
248247

248+
async function checkPasswordReset() {
249+
const resetPassword = process.env.ADMIN_RESET_PASSWORD;
250+
if (!resetPassword) return;
251+
252+
const bcrypt = await import("bcrypt");
253+
const adminUser = await prisma.user.findFirst({ where: { role: "ADMIN" } });
254+
if (!adminUser) {
255+
logger.warn("[Password Reset] No admin user found");
256+
return;
257+
}
258+
259+
const hashedPassword = await bcrypt.hash(resetPassword, 10);
260+
await prisma.user.update({
261+
where: { id: adminUser.id },
262+
data: { passwordHash: hashedPassword },
263+
});
264+
logger.warn("[Password Reset] Admin password has been reset via ADMIN_RESET_PASSWORD env var. Remove this env var and restart.");
265+
}
266+
249267
app.listen(config.port, "0.0.0.0", async () => {
250268
// Verify database connections before proceeding
251269
await checkPostgresConnection();
252270
await checkRedisConnection();
253271

272+
// Check for admin password reset
273+
await checkPasswordReset();
274+
254275
logger.debug(
255276
`Lidify API running on port ${config.port} (accessible on all network interfaces)`
256277
);

backend/src/middleware/rateLimiter.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,6 @@ export const authLimiter = rateLimit({
4747
...trustProxyValidation,
4848
});
4949

50-
// Media streaming limiter (higher limit: 200 streams/minute)
51-
export const streamLimiter = rateLimit({
52-
windowMs: 1 * 60 * 1000, // 1 minute
53-
max: 200, // Allow 200 stream requests per minute
54-
message: "Too many streaming requests, please slow down.",
55-
standardHeaders: true,
56-
legacyHeaders: false,
57-
...trustProxyValidation,
58-
});
5950

6051
// Image/Cover art limiter (very high limit: 500 req/minute)
6152
// This is for image proxying - not a security risk, just bandwidth

backend/src/routes/auth.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ router.post("/login", async (req, res) => {
8888
return res.status(200).json({
8989
requires2FA: true,
9090
message: "2FA token required",
91-
userId: user.id, // Send userId for next 2FA request
9291
});
9392
}
9493

backend/src/routes/browse.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,9 +344,8 @@ router.get("/all", async (req, res) => {
344344

345345
res.json({
346346
playlists: playlists.map(deezerPlaylistToUnified),
347-
radios: [], // Radio stations are now internal (use /api/library/radio)
347+
radios: [],
348348
genres,
349-
radiosByGenre: [], // Deprecated - use internal radios
350349
source: "deezer",
351350
});
352351
} catch (error: any) {

backend/src/routes/library.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,11 +1784,7 @@ router.get("/tracks/shuffle", async (req, res) => {
17841784
},
17851785
},
17861786
});
1787-
// Fisher-Yates shuffle
1788-
for (let i = tracksData.length - 1; i > 0; i--) {
1789-
const j = Math.floor(Math.random() * (i + 1));
1790-
[tracksData[i], tracksData[j]] = [tracksData[j], tracksData[i]];
1791-
}
1787+
tracksData = shuffleArray(tracksData);
17921788
} else {
17931789
// For large libraries, use database-level randomization
17941790
// Get random track IDs first (efficient, O(limit) memory)

backend/src/routes/onboarding.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import axios from "axios";
77
import crypto from "crypto";
88
import { encryptField } from "../utils/systemSettings";
99
import { writeEnvFile } from "../utils/envWriter";
10-
import { generateToken, requireAuth } from "../middleware/auth";
10+
import { generateToken, requireAuth, requireAdmin } from "../middleware/auth";
1111

1212
const router = Router();
1313

@@ -164,7 +164,7 @@ router.post("/register", async (req, res) => {
164164
* POST /onboarding/lidarr
165165
* Step 2a: Configure Lidarr integration
166166
*/
167-
router.post("/lidarr", requireAuth, async (req, res) => {
167+
router.post("/lidarr", requireAuth, requireAdmin, async (req, res) => {
168168
try {
169169
const config = lidarrConfigSchema.parse(req.body);
170170

@@ -243,7 +243,7 @@ router.post("/lidarr", requireAuth, async (req, res) => {
243243
* POST /onboarding/audiobookshelf
244244
* Step 2b: Configure Audiobookshelf integration
245245
*/
246-
router.post("/audiobookshelf", requireAuth, async (req, res) => {
246+
router.post("/audiobookshelf", requireAuth, requireAdmin, async (req, res) => {
247247
try {
248248
const config = audiobookshelfConfigSchema.parse(req.body);
249249

@@ -319,7 +319,7 @@ router.post("/audiobookshelf", requireAuth, async (req, res) => {
319319
* POST /onboarding/soulseek
320320
* Step 2c: Configure Soulseek integration (direct connection via slsk-client)
321321
*/
322-
router.post("/soulseek", requireAuth, async (req, res) => {
322+
router.post("/soulseek", requireAuth, requireAdmin, async (req, res) => {
323323
try {
324324
const config = soulseekConfigSchema.parse(req.body);
325325

@@ -377,7 +377,7 @@ router.post("/soulseek", requireAuth, async (req, res) => {
377377
* POST /onboarding/enrichment
378378
* Step 3: Configure metadata enrichment
379379
*/
380-
router.post("/enrichment", requireAuth, async (req, res) => {
380+
router.post("/enrichment", requireAuth, requireAdmin, async (req, res) => {
381381
try {
382382
const config = enrichmentConfigSchema.parse(req.body);
383383

backend/src/routes/podcasts.ts

Lines changed: 62 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -787,78 +787,22 @@ router.get("/:id/refresh", async (req, res) => {
787787
logger.debug(`\n [PODCAST] Refresh request`);
788788
logger.debug(` Podcast ID: ${id}`);
789789

790-
const podcast = await prisma.podcast.findUnique({
791-
where: { id },
792-
});
793-
794-
if (!podcast) {
795-
return res.status(404).json({ error: "Podcast not found" });
796-
}
797-
798-
// Parse RSS feed
799-
logger.debug(` Parsing RSS feed...`);
800-
const { podcast: podcastData, episodes } =
801-
await rssParserService.parseFeed(podcast.feedUrl);
802-
803-
// Update podcast metadata
804-
await prisma.podcast.update({
805-
where: { id },
806-
data: {
807-
title: podcastData.title,
808-
author: podcastData.author,
809-
description: podcastData.description,
810-
imageUrl: podcastData.imageUrl,
811-
language: podcastData.language,
812-
explicit: podcastData.explicit || false,
813-
episodeCount: episodes.length,
814-
lastRefreshed: new Date(),
815-
},
816-
});
817-
818-
// Add new episodes (skip duplicates)
819-
let newEpisodesCount = 0;
820-
for (const ep of episodes) {
821-
const existing = await prisma.podcastEpisode.findUnique({
822-
where: {
823-
podcastId_guid: {
824-
podcastId: id,
825-
guid: ep.guid,
826-
},
827-
},
828-
});
829-
830-
if (!existing) {
831-
await prisma.podcastEpisode.create({
832-
data: {
833-
podcastId: id,
834-
guid: ep.guid,
835-
title: ep.title,
836-
description: ep.description,
837-
audioUrl: ep.audioUrl,
838-
duration: ep.duration,
839-
publishedAt: ep.publishedAt,
840-
episodeNumber: ep.episodeNumber,
841-
season: ep.season,
842-
imageUrl: ep.imageUrl,
843-
fileSize: ep.fileSize,
844-
mimeType: ep.mimeType,
845-
},
846-
});
847-
newEpisodesCount++;
848-
}
849-
}
790+
const result = await refreshPodcastFeed(id);
850791

851792
logger.debug(
852-
` Refresh complete. ${newEpisodesCount} new episodes added.`
793+
` Refresh complete. ${result.newEpisodesCount} new episodes added.`
853794
);
854795

855796
res.json({
856797
success: true,
857-
newEpisodesCount,
858-
totalEpisodes: episodes.length,
859-
message: `Found ${newEpisodesCount} new episodes`,
798+
newEpisodesCount: result.newEpisodesCount,
799+
totalEpisodes: result.totalEpisodes,
800+
message: `Found ${result.newEpisodesCount} new episodes`,
860801
});
861802
} catch (error: any) {
803+
if (error.message?.includes("not found")) {
804+
return res.status(404).json({ error: "Podcast not found" });
805+
}
862806
logger.error("Error refreshing podcast:", error);
863807
res.status(500).json({
864808
error: "Failed to refresh podcast",
@@ -1657,4 +1601,58 @@ router.get("/episodes/:episodeId/cover", async (req, res) => {
16571601
}
16581602
});
16591603

1604+
/**
1605+
* Refresh a single podcast feed -- shared logic used by both the route handler
1606+
* and the enrichment worker's automatic refresh phase.
1607+
*/
1608+
export async function refreshPodcastFeed(podcastId: string): Promise<{ newEpisodesCount: number; totalEpisodes: number }> {
1609+
const podcast = await prisma.podcast.findUnique({ where: { id: podcastId } });
1610+
if (!podcast) throw new Error(`Podcast ${podcastId} not found`);
1611+
1612+
const { podcast: podcastData, episodes } = await rssParserService.parseFeed(podcast.feedUrl);
1613+
1614+
await prisma.podcast.update({
1615+
where: { id: podcastId },
1616+
data: {
1617+
title: podcastData.title,
1618+
author: podcastData.author,
1619+
description: podcastData.description,
1620+
imageUrl: podcastData.imageUrl,
1621+
language: podcastData.language,
1622+
explicit: podcastData.explicit || false,
1623+
episodeCount: episodes.length,
1624+
lastRefreshed: new Date(),
1625+
},
1626+
});
1627+
1628+
let newEpisodesCount = 0;
1629+
for (const ep of episodes) {
1630+
const existing = await prisma.podcastEpisode.findUnique({
1631+
where: { podcastId_guid: { podcastId, guid: ep.guid } },
1632+
});
1633+
1634+
if (!existing) {
1635+
await prisma.podcastEpisode.create({
1636+
data: {
1637+
podcastId,
1638+
guid: ep.guid,
1639+
title: ep.title,
1640+
description: ep.description,
1641+
audioUrl: ep.audioUrl,
1642+
duration: ep.duration,
1643+
publishedAt: ep.publishedAt,
1644+
episodeNumber: ep.episodeNumber,
1645+
season: ep.season,
1646+
imageUrl: ep.imageUrl,
1647+
fileSize: ep.fileSize,
1648+
mimeType: ep.mimeType,
1649+
},
1650+
});
1651+
newEpisodesCount++;
1652+
}
1653+
}
1654+
1655+
return { newEpisodesCount, totalEpisodes: episodes.length };
1656+
}
1657+
16601658
export default router;

0 commit comments

Comments
 (0)