Skip to content

Commit 51c7617

Browse files
author
Your Name
committed
release: v1.5.9 — iOS MediaSession fix, enrichment pipeline hardening, track formatting
Features: - DISABLE_CLAP env var for low-memory deployments - Foobar2000-style track title formatting in Settings > Playback - Partial playlist creation on cancelled Spotify imports Fixes: - iOS lock screen controls showing inverted state (play/pause swapped) - Enrichment pipeline: 7 fixes covering vibe sweep, crash recovery, CLAP supervisor, completion detection, embedding reset, feature detection - Favicon replaced with waveform-only multi-size ICO - docker-compose.server.yml healthcheck using removed wget
1 parent caf3cae commit 51c7617

File tree

19 files changed

+525
-242
lines changed

19 files changed

+525
-242
lines changed

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ VERSION=latest
8484
# AUDIO_MODEL_IDLE_TIMEOUT=300 # Seconds before unloading ML models when idle (default: 300)
8585
# # Set to 0 to keep models loaded permanently
8686

87+
# CLAP Audio Analyzer (AI vibe/mood matching)
88+
# Set to 'true' to disable the CLAP embedding analyzer on startup.
89+
# Useful for low-memory deployments or when AI vibe matching is not needed.
90+
# Only applies to the all-in-one container (docker-compose.prod.yml).
91+
# For split containers (docker-compose.yml), simply don't start the audio-analyzer-clap service.
92+
# DISABLE_CLAP=true
93+
8794
# ==============================================================================
8895
# OPTIONAL: Lidarr Webhook Configuration
8996
# ==============================================================================

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,27 @@ All notable changes to Kima 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.5.9] - 2026-02-27
9+
10+
### Added
11+
12+
- **#122** `DISABLE_CLAP=true` environment variable to disable the CLAP audio embedding analyzer on startup in the all-in-one container (useful for low-memory deployments)
13+
- **#123** Foobar2000-style track title formatting in Settings > Playback -- configure a format string with `%field%`, `[conditional blocks]`, `$if2()`, `$filepart()` syntax; applied in playlist view
14+
- **#124** Cancelling a playlist import now creates a partial playlist from all tracks already matched to your library, instead of discarding progress
15+
16+
### Fixed
17+
18+
- **#124** Cancel button previously promised "Playlist will be created with tracks downloaded so far" but discarded all progress -- now delivers on that promise
19+
- **iOS lock screen controls inverted**: MediaSession `playbackState` was driven by React `useEffect` on `isPlaying` state, which fires asynchronously after render -- not synchronously with the actual audio state change. This caused lock screen controls to show the opposite state (play when playing, pause when paused). Rewrote MediaSession to drive `playbackState` directly from `audioEngine` events, call the engine directly from action handlers to preserve iOS user-gesture context, and use ref-based one-time handler registration to avoid re-registration churn.
20+
- **Favicon showing old Lidify icon or wrong Kima logo**: Browser tab showed the pre-rebrand Lidify favicon. Replaced with the waveform-only icon generated from `kima-black.webp` as a proper multi-size ICO (16/32/48/64/128/256px) with tight cropping so the waveform fills the tab space.
21+
- **Enrichment pipeline: no periodic vibe sweep**: The enrichment cycle had no phase for queueing vibe/CLAP embedding jobs. The only automatic path was a lossy pub/sub event from Essentia completion -- if missed (crash, restart, migration wipe), tracks were orphaned forever. Added Phase 5 that sweeps for tracks with completed audio but missing embedding rows via LEFT JOIN.
22+
- **Enrichment pipeline: crash recovery dead end**: Crash recovery reset `vibeAnalysisStatus` from `processing` to `null`, which nothing in the regular cycle re-queued. Changed to reset to `pending` so the periodic sweep picks them up.
23+
- **Enrichment pipeline: CLAP analyzer permanent death**: When enrichment was stopped, the backend sent a stop command causing the CLAP analyzer to exit cleanly (code 0). Supervisor's `autorestart=unexpected` treated this as expected and never restarted. Changed to `autorestart=true` and removed the stop signal entirely -- the analyzer has its own idle timeout.
24+
- **Enrichment pipeline: completion never triggers**: `isFullyComplete` required `clapCompleted + clapFailed >= trackTotal`, which was impossible after `track_embeddings` was wiped by migration. Now checks for actual un-embedded tracks via LEFT JOIN.
25+
- **Enrichment pipeline: "Reset Vibe Embeddings" incomplete**: `reRunVibeEmbeddingsOnly()` reset `vibeAnalysisStatus` but did not delete existing `track_embeddings` rows, so the re-queue query (which uses LEFT JOIN) silently skipped tracks that already had embeddings. Now deletes all embeddings first for full regeneration.
26+
- **Feature detection: CLAP reported available when disabled**: When `DISABLE_CLAP=true` was set, `checkCLAP()` skipped the file-existence check but still fell through to heartbeat and data checks. If old embeddings existed in the database, it returned `true`, causing the vibe sweep to queue jobs that no CLAP worker would ever process. Now returns `false` immediately when disabled.
27+
- **docker-compose.server.yml healthcheck using removed tool**: Healthcheck used `wget` which is removed from the production image during security hardening. Changed to `node /app/healthcheck.js` to match docker-compose.prod.yml.
28+
829
## [1.5.8] - 2026-02-26
930

1031
### Fixed

Dockerfile

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ priority=50
276276
[program:audio-analyzer-clap]
277277
command=/bin/bash -c "/app/wait-for-db.sh 120 && cd /app/audio-analyzer-clap && python3 analyzer.py"
278278
autostart=true
279-
autorestart=unexpected
279+
autorestart=true
280280
startretries=3
281281
startsecs=30
282282
stdout_logfile=/dev/stdout
@@ -490,8 +490,25 @@ TRANSCODE_CACHE_PATH=/data/cache/transcodes
490490
SESSION_SECRET=$SESSION_SECRET
491491
SETTINGS_ENCRYPTION_KEY=$SETTINGS_ENCRYPTION_KEY
492492
INTERNAL_API_SECRET=kima-internal-aio
493+
DISABLE_CLAP=${DISABLE_CLAP:-}
493494
ENVEOF
494495

496+
# Optionally disable CLAP audio analyzer (for low-memory deployments)
497+
if [ "${DISABLE_CLAP:-false}" = "true" ] || [ "${DISABLE_CLAP:-0}" = "1" ]; then
498+
python3 -c "
499+
import re
500+
conf = open('/etc/supervisor/conf.d/kima.conf').read()
501+
conf = re.sub(
502+
r'(\[program:audio-analyzer-clap\][^\[]*autostart=)true',
503+
r'\g<1>false',
504+
conf,
505+
flags=re.DOTALL
506+
)
507+
open('/etc/supervisor/conf.d/kima.conf', 'w').write(conf)
508+
"
509+
echo "CLAP audio analyzer disabled (DISABLE_CLAP=${DISABLE_CLAP})"
510+
fi
511+
495512
echo "Starting Kima..."
496513
exec env \
497514
NODE_ENV=production \

backend/package.json

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

backend/src/services/featureDetection.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,12 @@ class FeatureDetectionService {
6868

6969
private async checkCLAP(): Promise<boolean> {
7070
try {
71-
// Analyzer script bundled in image = feature is available
71+
// If explicitly disabled via env var, CLAP is not available
72+
const disabled = process.env.DISABLE_CLAP;
73+
if (disabled === "true" || disabled === "1") {
74+
return false;
75+
}
76+
7277
if (existsSync(CLAP_ANALYZER_PATH)) {
7378
return true;
7479
}

backend/src/services/spotifyImport.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2644,16 +2644,57 @@ class SpotifyImportService {
26442644
},
26452645
});
26462646

2647-
// Mark job as cancelled - do NOT create a playlist
2647+
// Collect tracks already matched to the library before cancellation
2648+
const matchedTrackIds = [
2649+
...new Set(
2650+
(job.pendingTracks || [])
2651+
.map((t) => t.preMatchedTrackId)
2652+
.filter((id): id is string => !!id),
2653+
),
2654+
];
2655+
2656+
let createdPlaylistId: string | null = null;
2657+
2658+
if (matchedTrackIds.length > 0) {
2659+
try {
2660+
const playlist = await prisma.playlist.create({
2661+
data: {
2662+
userId: job.userId,
2663+
name: job.playlistName,
2664+
isPublic: false,
2665+
spotifyPlaylistId: job.spotifyPlaylistId,
2666+
items: {
2667+
create: matchedTrackIds.map((trackId, index) => ({
2668+
trackId,
2669+
sort: index,
2670+
})),
2671+
},
2672+
},
2673+
});
2674+
createdPlaylistId = playlist.id;
2675+
logger?.info(
2676+
`Partial playlist created with ${matchedTrackIds.length} tracks: ${playlist.id}`,
2677+
);
2678+
} catch (err: any) {
2679+
logger?.warn(
2680+
`Failed to create partial playlist on cancel: ${err?.message}`,
2681+
);
2682+
}
2683+
}
2684+
26482685
job.status = "cancelled";
2686+
job.createdPlaylistId = createdPlaylistId;
2687+
job.tracksMatched = matchedTrackIds.length;
26492688
job.updatedAt = new Date();
26502689
await saveImportJob(job);
2651-
logger?.info(`Import cancelled by user - no playlist created`);
2690+
logger?.info(
2691+
`Import cancelled by user — ${createdPlaylistId ? "partial playlist created" : "no tracks matched"}`,
2692+
);
26522693

26532694
return {
2654-
playlistCreated: false,
2655-
playlistId: null,
2656-
tracksMatched: 0,
2695+
playlistCreated: !!createdPlaylistId,
2696+
playlistId: createdPlaylistId,
2697+
tracksMatched: matchedTrackIds.length,
26572698
};
26582699
}
26592700

backend/src/workers/unifiedEnrichment.ts

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,9 @@ async function setupControlChannel() {
221221
"[Enrichment] Stopping gracefully - completing current item...",
222222
);
223223
// DO NOT override state - let enrichmentStateService.stop() handle it
224-
// Signal CLAP Python container to stop draining its queue
225-
getRedis().publish("audio:clap:control", JSON.stringify({ command: "stop" })).catch(() => {});
224+
// DO NOT kill the CLAP analyzer — it has its own idle timeout (MODEL_IDLE_TIMEOUT=300s)
225+
// and will unload the model when the vibe queue is empty. Killing it caused
226+
// permanent death because supervisor's autorestart didn't revive clean exits.
226227
}
227228
}
228229
});
@@ -248,7 +249,7 @@ export async function startUnifiedEnrichmentWorker() {
248249
});
249250
const orphanedVibe = await prisma.track.updateMany({
250251
where: { vibeAnalysisStatus: "processing" },
251-
data: { vibeAnalysisStatus: null, vibeAnalysisStartedAt: null },
252+
data: { vibeAnalysisStatus: "pending", vibeAnalysisStartedAt: null },
252253
});
253254
if (orphanedAudio.count > 0 || orphanedVibe.count > 0) {
254255
logger.info(
@@ -496,6 +497,9 @@ async function runEnrichmentCycle(fullMode: boolean): Promise<{
496497
// Podcast refresh phase -- only runs if subscriptions exist
497498
await runPhase("podcasts", executePodcastRefreshPhase);
498499

500+
// Vibe embedding sweep — catches tracks missed by the event-driven subscriber
501+
await runPhase("vibe", executeVibePhase);
502+
499503
// Orphaned failure cleanup -- runs at most once per hour, never during stop/pause
500504
const ONE_HOUR_MS = 60 * 60 * 1000;
501505
if (!isStopping && !isPaused && (!lastOrphanedFailuresCleanup || Date.now() - lastOrphanedFailuresCleanup.getTime() > ONE_HOUR_MS)) {
@@ -853,7 +857,7 @@ async function shouldHaltCycle(): Promise<boolean> {
853857
* Run a phase and return result. Returns null if cycle should halt.
854858
*/
855859
async function runPhase(
856-
phaseName: "artists" | "tracks" | "audio" | "podcasts",
860+
phaseName: "artists" | "tracks" | "audio" | "podcasts" | "vibe",
857861
executor: () => Promise<number>,
858862
): Promise<number | null> {
859863
await enrichmentStateService.updateState({
@@ -1014,6 +1018,69 @@ async function executeAudioPhase(): Promise<number> {
10141018
return queueAudioAnalysis();
10151019
}
10161020

1021+
const VIBE_SWEEP_BATCH_SIZE = 100;
1022+
1023+
async function executeVibePhase(): Promise<number> {
1024+
const features = await featureDetection.getFeatures();
1025+
if (!features.vibeEmbeddings) {
1026+
return 0;
1027+
}
1028+
1029+
// Find tracks with completed audio analysis but no embedding row.
1030+
// This catches:
1031+
// - Tracks orphaned by migration wiping track_embeddings
1032+
// - Tracks whose pub/sub completion event was missed (crash, restart)
1033+
// - Tracks reset to null/pending by crash recovery
1034+
// - Tracks with vibeAnalysisStatus='completed' but no actual embedding (stale status)
1035+
const tracks = await prisma.$queryRaw<{ id: string; filePath: string }[]>`
1036+
SELECT t.id, t."filePath"
1037+
FROM "Track" t
1038+
LEFT JOIN track_embeddings te ON t.id = te.track_id
1039+
WHERE te.track_id IS NULL
1040+
AND t."analysisStatus" = 'completed'
1041+
AND t."filePath" IS NOT NULL
1042+
AND (t."vibeAnalysisStatus" IS NULL
1043+
OR t."vibeAnalysisStatus" = 'pending'
1044+
OR t."vibeAnalysisStatus" = 'completed')
1045+
AND (t."vibeAnalysisStatus" IS DISTINCT FROM 'processing')
1046+
LIMIT ${VIBE_SWEEP_BATCH_SIZE}
1047+
`;
1048+
1049+
if (tracks.length === 0) {
1050+
return 0;
1051+
}
1052+
1053+
// Reset stale vibeAnalysisStatus for these tracks before queuing
1054+
const trackIds = tracks.map((t) => t.id);
1055+
await prisma.track.updateMany({
1056+
where: { id: { in: trackIds } },
1057+
data: {
1058+
vibeAnalysisStatus: "pending",
1059+
vibeAnalysisError: null,
1060+
},
1061+
});
1062+
1063+
let queued = 0;
1064+
for (const track of tracks) {
1065+
try {
1066+
await vibeQueue.add(
1067+
"embed",
1068+
{ trackId: track.id, filePath: track.filePath },
1069+
{ jobId: `vibe-${track.id}` },
1070+
);
1071+
queued++;
1072+
} catch (err) {
1073+
// jobId dedup: if already queued, BullMQ throws — that's fine
1074+
}
1075+
}
1076+
1077+
if (queued > 0) {
1078+
logger.debug(`[Enrichment] Vibe sweep: queued ${queued} tracks for embedding`);
1079+
}
1080+
1081+
return queued;
1082+
}
1083+
10171084
async function executePodcastRefreshPhase(): Promise<number> {
10181085
const podcastCount = await prisma.podcast.count();
10191086
if (podcastCount === 0) return 0;
@@ -1094,7 +1161,7 @@ export async function getEnrichmentProgress() {
10941161
});
10951162

10961163
// CLAP embedding progress (for vibe similarity)
1097-
const [clapEmbeddingCount, clapProcessing, clapQueueCounts, clapFailedCount] = await Promise.all([
1164+
const [clapEmbeddingCount, clapProcessing, clapQueueCounts, clapFailedCount, clapUnembeddedCount] = await Promise.all([
10981165
prisma.$queryRaw<{ count: bigint }[]>`
10991166
SELECT COUNT(*) as count FROM track_embeddings
11001167
`,
@@ -1105,10 +1172,21 @@ export async function getEnrichmentProgress() {
11051172
prisma.track.count({
11061173
where: { vibeAnalysisStatus: "failed" },
11071174
}),
1175+
// Tracks with completed audio but no embedding and not failed
1176+
prisma.$queryRaw<{ count: bigint }[]>`
1177+
SELECT COUNT(*) as count
1178+
FROM "Track" t
1179+
LEFT JOIN track_embeddings te ON t.id = te.track_id
1180+
WHERE te.track_id IS NULL
1181+
AND t."analysisStatus" = 'completed'
1182+
AND t."filePath" IS NOT NULL
1183+
AND (t."vibeAnalysisStatus" IS DISTINCT FROM 'failed')
1184+
`,
11081185
]);
11091186
const clapQueueLength = (clapQueueCounts.active ?? 0) + (clapQueueCounts.waiting ?? 0) + (clapQueueCounts.delayed ?? 0);
11101187
const clapCompleted = Number(clapEmbeddingCount[0]?.count || 0);
11111188
const clapFailed = clapFailedCount;
1189+
const clapUnembedded = Number(clapUnembeddedCount[0]?.count || 0);
11121190

11131191
// Core enrichment is complete when artists and track tags are done
11141192
// Audio analysis is separate - it runs in background and doesn't block
@@ -1175,7 +1253,7 @@ export async function getEnrichmentProgress() {
11751253
audioProcessing === 0 &&
11761254
clapProcessing === 0 &&
11771255
clapQueueLength === 0 &&
1178-
clapCompleted + clapFailed >= trackTotal,
1256+
clapUnembedded === 0,
11791257
};
11801258
}
11811259

@@ -1293,12 +1371,14 @@ export async function triggerEnrichmentNow(): Promise<{
12931371
return 0;
12941372
}
12951373

1374+
// Delete all existing embeddings so tracks get fully regenerated
1375+
const deleted = await prisma.trackEmbedding.deleteMany({});
1376+
logger.debug(`[Enrichment] Deleted ${deleted.count} existing embeddings for full regeneration`);
1377+
12961378
// Reset all tracks so they can be re-embedded.
1297-
// Only reset tracks whose audio analysis is complete (no point embedding incomplete audio).
12981379
await prisma.track.updateMany({
12991380
where: {
13001381
analysisStatus: "completed",
1301-
vibeAnalysisStatus: { not: null },
13021382
},
13031383
data: {
13041384
vibeAnalysisStatus: null,
@@ -1311,9 +1391,7 @@ export async function triggerEnrichmentNow(): Promise<{
13111391
const tracks = await prisma.$queryRaw<{ id: string; filePath: string }[]>`
13121392
SELECT t.id, t."filePath"
13131393
FROM "Track" t
1314-
LEFT JOIN track_embeddings te ON t.id = te.track_id
1315-
WHERE te.track_id IS NULL
1316-
AND t."analysisStatus" = 'completed'
1394+
WHERE t."analysisStatus" = 'completed'
13171395
AND t."filePath" IS NOT NULL
13181396
LIMIT 5000
13191397
`;

docker-compose.prod.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ services:
2929
# Default uses host.docker.internal which works on most setups with extra_hosts below
3030
# Override if using custom Docker networks: e.g., http://192.168.0.20:3030
3131
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3030}
32+
- DISABLE_CLAP=${DISABLE_CLAP:-}
3233
# Makes host.docker.internal work on Linux (already works on Docker Desktop)
3334
extra_hosts:
3435
- "host.docker.internal:host-gateway"

docker-compose.server.yml

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ services:
2727
# Default uses host.docker.internal which works on most setups with extra_hosts below
2828
# Override if using custom Docker networks: e.g., http://192.168.0.20:3030
2929
- KIMA_CALLBACK_URL=${KIMA_CALLBACK_URL:-http://host.docker.internal:3030}
30+
- DISABLE_CLAP=${DISABLE_CLAP:-}
3031
# Makes host.docker.internal work on Linux (already works on Docker Desktop)
3132
extra_hosts:
3233
- "host.docker.internal:host-gateway"
@@ -35,15 +36,7 @@ services:
3536
- vm.overcommit_memory=1
3637
restart: unless-stopped
3738
healthcheck:
38-
test:
39-
[
40-
"CMD",
41-
"wget",
42-
"--no-verbose",
43-
"--tries=1",
44-
"--spider",
45-
"http://localhost:3030",
46-
]
39+
test: ["CMD", "node", "/app/healthcheck.js"]
4740
interval: 30s
4841
timeout: 10s
4942
retries: 3

frontend/app/favicon.ico

95.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)