From 84dc5a934dbca78550e6e38324cd2a45e280adc5 Mon Sep 17 00:00:00 2001 From: chevron7 Date: Mon, 15 Jun 2026 08:34:08 -0500 Subject: [PATCH] fix: surface Subsonic write failures, guard podcast sort, de-spam analyzer log - Subsonic star.view swallowed every error and returned success, so a third-party app could star a track that never saved. Now only a P2003 FK violation (track legitimately missing) is absorbed; any other error is logged and returns a Subsonic error. Scrobble play-log failures are logged instead of silently discarded. - The podcasts page sorted by author/title with a raw localeCompare on an optional field, so one feed with no author crashed the whole page via the error boundary. Comparators are now null-guarded. - The audio analyzer re-logged the same 'N tracks permanently failed' warning every idle cycle (~50s) forever; it now logs only when the count changes. --- backend/src/routes/subsonic/playback.ts | 3 ++- backend/src/routes/subsonic/starred.ts | 14 ++++++++++---- frontend/app/podcasts/page.tsx | 4 ++-- services/audio-analyzer/analyzer.py | 7 +++++-- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/subsonic/playback.ts b/backend/src/routes/subsonic/playback.ts index 021ca77..ea32d52 100644 --- a/backend/src/routes/subsonic/playback.ts +++ b/backend/src/routes/subsonic/playback.ts @@ -3,6 +3,7 @@ import fs from "fs"; import path from "path"; import { ListenSource } from "@prisma/client"; import { prisma } from "../../utils/db"; +import { logger } from "../../utils/logger"; import { subsonicOk, subsonicError, SubsonicError } from "../../utils/subsonicResponse"; import { getAudioStreamingService } from "../../services/audioStreaming"; import { config } from "../../config"; @@ -326,7 +327,7 @@ playbackRouter.all("/scrobble.view", wrap(async (req, res) => { const playedAt = isNaN(timeMs) ? new Date() : new Date(timeMs); await prisma.play .create({ data: { userId, trackId: id, playedAt, source: ListenSource.SUBSONIC } }) - .catch(() => {}); + .catch((err) => logger.warn("[Subsonic] scrobble play-log failed:", err)); } } diff --git a/backend/src/routes/subsonic/starred.ts b/backend/src/routes/subsonic/starred.ts index b06fb1c..bd5d6b3 100644 --- a/backend/src/routes/subsonic/starred.ts +++ b/backend/src/routes/subsonic/starred.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { prisma } from "../../utils/db"; +import { logger } from "../../utils/logger"; import { subsonicOk, subsonicError, SubsonicError } from "../../utils/subsonicResponse"; import { mapSong, firstArtistGenre, wrap, parseRepeatedQueryParam } from "./mappers"; @@ -81,13 +82,18 @@ starredRouter.all("/star.view", wrap(async (req, res) => { const ids = parseRepeatedQueryParam(req.query.id); for (const trackId of ids) { - await prisma.likedTrack - .upsert({ + try { + await prisma.likedTrack.upsert({ where: { userId_trackId: { userId, trackId } }, create: { userId, trackId }, update: {}, - }) - .catch(() => {}); // Absorbs FK violation if trackId doesn't exist + }); + } catch (err) { + // P2003 = FK violation: trackId doesn't exist. Absorb silently. + if ((err as { code?: string }).code === "P2003") continue; + logger.warn("[Subsonic] star failed:", err); + return subsonicError(req, res, SubsonicError.GENERIC, "Failed to star track"); + } } return subsonicOk(req, res); })); diff --git a/frontend/app/podcasts/page.tsx b/frontend/app/podcasts/page.tsx index df7c2d4..d91f513 100644 --- a/frontend/app/podcasts/page.tsx +++ b/frontend/app/podcasts/page.tsx @@ -189,10 +189,10 @@ export default function PodcastsPage() { const sorted = [...podcasts]; switch (sortBy) { case "title": - sorted.sort((a, b) => a.title.localeCompare(b.title)); + sorted.sort((a, b) => (a.title || "").localeCompare(b.title || "")); break; case "author": - sorted.sort((a, b) => a.author.localeCompare(b.author)); + sorted.sort((a, b) => (a.author || "").localeCompare(b.author || "")); break; case "recent": sorted.sort((a, b) => (b.episodeCount || 0) - (a.episodeCount || 0)); diff --git a/services/audio-analyzer/analyzer.py b/services/audio-analyzer/analyzer.py index 05f030d..7480da6 100644 --- a/services/audio-analyzer/analyzer.py +++ b/services/audio-analyzer/analyzer.py @@ -1160,6 +1160,7 @@ class AnalysisWorker: self._last_work_time = time.time() self._pending_resize: int | None = None self._pending_resize_time: float = 0.0 + self._last_perm_failed_count: int | None = None self._setup_control_channel() def _setup_control_channel(self): @@ -1503,8 +1504,10 @@ class AnalysisWorker: """, (MAX_RETRIES,)) perm_failed = cursor.fetchone() - if perm_failed and perm_failed['count'] > 0: - logger.warning(f"{perm_failed['count']} tracks have permanently failed (exceeded {MAX_RETRIES} retries)") + perm_failed_count = perm_failed['count'] if perm_failed else 0 + if perm_failed_count > 0 and perm_failed_count != self._last_perm_failed_count: + logger.warning(f"{perm_failed_count} tracks have permanently failed (exceeded {MAX_RETRIES} retries)") + self._last_perm_failed_count = perm_failed_count self.db.commit() except Exception as e: