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.
This commit is contained in:
chevron7
2026-06-15 08:34:08 -05:00
parent dd9b346bc9
commit 84dc5a934d
4 changed files with 19 additions and 9 deletions
+2 -1
View File
@@ -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));
}
}
+10 -4
View File
@@ -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);
}));
+2 -2
View File
@@ -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));
+5 -2
View File
@@ -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: