chore: Release v1.3.3 - Critical bug fixes and QoL improvements

Critical Fixes:
- Docker permissions for PostgreSQL/Redis bind mounts
  Fixes #59, fixes #62
- Audio analyzer memory consumption and OOM crashes
  Fixes #21, fixes #26, fixes #53
- LastFM array normalization preventing .map crashes
  Fixes #37, fixes #39
- Wikidata 403 errors from missing User-Agent
  Fixes #57
- Singles directory creation race conditions
  Fixes #58
- Firefox FLAC playback stopping at ~4:34 mark
  Fixes #42, fixes #17

Quality of Life:
- Add Releases link to desktop sidebar navigation
  Fixes #41
- iPhone safe area insets for Dynamic Island/notch
  Fixes #54

Contributors: @arsaboo, @rustyricky, @RustyJonez, @tombatossals

No regressions detected, backward compatible, production ready.
This commit is contained in:
Your Name
2026-01-09 18:31:45 -06:00
parent ce597a318e
commit 0ac805b6fc
17 changed files with 857 additions and 94 deletions
+24 -7
View File
@@ -5,6 +5,7 @@ import { musicBrainzService } from "../services/musicbrainz";
import { fanartService } from "../services/fanart";
import { deezerService } from "../services/deezer";
import { redisClient } from "../utils/redis";
import { normalizeToArray } from "../utils/normalize";
const router = Router();
@@ -158,8 +159,10 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
}
// Fallback to Last.fm (but filter placeholders)
// NORMALIZATION: lastFmInfo.image could be a single object or array
if (!image && lastFmInfo?.image) {
const lastFmImage = lastFmService.getBestImage(lastFmInfo.image);
const images = normalizeToArray(lastFmInfo.image);
const lastFmImage = lastFmService.getBestImage(images);
// Filter out Last.fm placeholder
if (
lastFmImage &&
@@ -274,10 +277,13 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
}
// Get similar artists from Last.fm and fetch images
const similarArtistsRaw = lastFmInfo?.similar?.artist || [];
// NORMALIZATION: lastFmInfo.similar.artist could be a single object or array
const similarArtistsRaw = normalizeToArray(lastFmInfo?.similar?.artist);
const similarArtists = await Promise.all(
similarArtistsRaw.slice(0, 10).map(async (artist: any) => {
const similarImage = artist.image?.find(
// NORMALIZATION: artist.image could be a single object or array
const images = normalizeToArray(artist.image);
const similarImage = images.find(
(img: any) => img.size === "large"
)?.[" #text"];
@@ -325,14 +331,19 @@ router.get("/discover/:nameOrMbid", async (req, res) => {
})
);
// NORMALIZATION: lastFmInfo.tags.tag could be a single object or array
const tags = normalizeToArray(lastFmInfo?.tags?.tag)
.map((t: any) => t?.name)
.filter(Boolean);
const response = {
mbid,
name: artistName,
image,
bio, // Use filtered bio instead of raw Last.fm bio
summary: bio, // Alias for consistency
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
genres: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [], // Alias for consistency
tags,
genres: tags, // Alias for consistency
listeners: parseInt(lastFmInfo?.stats?.listeners || "0"),
playcount: parseInt(lastFmInfo?.stats?.playcount || "0"),
url: lastFmInfo?.url || null,
@@ -470,7 +481,10 @@ router.get("/album/:mbid", async (req, res) => {
// Check if Cover Art Archive actually has the image
try {
const response = await fetch(coverArtUrl, { method: "HEAD" });
const response = await fetch(coverArtUrl, {
method: "HEAD",
signal: AbortSignal.timeout(2000),
});
if (response.ok) {
coverUrl = coverArtUrl;
logger.debug(`Cover Art Archive has cover for ${albumTitle}`);
@@ -529,7 +543,10 @@ router.get("/album/:mbid", async (req, res) => {
coverUrl,
coverArt: coverUrl, // Alias for compatibility
bio: lastFmInfo?.wiki?.summary || null,
tags: lastFmInfo?.tags?.tag?.map((t: any) => t.name) || [],
// NORMALIZATION: lastFmInfo.tags.tag could be a single object or array
tags: normalizeToArray(lastFmInfo?.tags?.tag)
.map((t: any) => t?.name)
.filter(Boolean),
tracks: tracks.map((track: any, index: number) => ({
id: `mb-${releaseGroupId}-${track.id || index}`,
title: track.title,
+8 -50
View File
@@ -2754,32 +2754,12 @@ router.get("/tracks/:id/stream", async (req, res) => {
`[STREAM] Sending file: ${filePath}, mimeType: ${mimeType}`
);
res.sendFile(
filePath,
{
headers: {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Access-Control-Allow-Origin":
req.headers.origin || "*",
"Access-Control-Allow-Credentials": "true",
"Cross-Origin-Resource-Policy": "cross-origin",
},
},
(err) => {
// Always destroy the streaming service to clean up intervals
streamingService.destroy();
if (err) {
logger.error(`[STREAM] sendFile error:`, err);
} else {
logger.debug(
`[STREAM] File sent successfully: ${path.basename(
filePath
)}`
);
}
}
await streamingService.streamFileWithRangeSupport(req, res, filePath, mimeType);
streamingService.destroy();
logger.debug(
`[STREAM] File sent successfully: ${path.basename(
filePath
)}`
);
return;
@@ -2812,30 +2792,8 @@ router.get("/tracks/:id/stream", async (req, res) => {
absolutePath
);
res.sendFile(
filePath,
{
headers: {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Access-Control-Allow-Origin":
req.headers.origin || "*",
"Access-Control-Allow-Credentials": "true",
"Cross-Origin-Resource-Policy": "cross-origin",
},
},
(err) => {
// Always destroy the streaming service to clean up intervals
streamingService.destroy();
if (err) {
logger.error(
`[STREAM] sendFile fallback error:`,
err
);
}
}
);
await streamingService.streamFileWithRangeSupport(req, res, filePath, mimeType);
streamingService.destroy();
return;
}