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;
}
+91
View File
@@ -1,4 +1,6 @@
import * as fs from "fs";
import { promises as fsPromises } from "fs";
import { Request, Response } from "express";
import { logger } from "../utils/logger";
import * as path from "path";
import * as crypto from "crypto";
@@ -384,6 +386,95 @@ export class AudioStreamingService {
return mimeTypes[ext] || "audio/mpeg";
}
/**
* Stream file with proper HTTP Range support (fixes Firefox FLAC issue #42/#17)
* Manually handles Range requests to ensure compatibility with Firefox's strict
* Content-Range header validation for large FLAC files.
*/
async streamFileWithRangeSupport(
req: Request,
res: Response,
filePath: string,
mimeType: string
): Promise<void> {
try {
// Get file stats for size
const stats = await fsPromises.stat(filePath);
const fileSize = stats.size;
// Parse Range header
const range = req.headers.range;
let start = 0;
let end = fileSize - 1;
if (range) {
// Parse bytes=START-END or bytes=START-
const parts = range.replace(/bytes=/, "").split("-");
start = parseInt(parts[0], 10);
end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
// Validate range
if (start >= fileSize || end >= fileSize || start > end) {
res.status(416).set({
"Content-Range": `bytes */${fileSize}`,
});
res.end();
return;
}
}
const contentLength = end - start + 1;
// Set response headers
const headers: Record<string, string> = {
"Content-Type": mimeType,
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000",
"Content-Length": contentLength.toString(),
};
// Add CORS headers from request origin
if (req.headers.origin) {
headers["Access-Control-Allow-Origin"] = req.headers.origin;
headers["Access-Control-Allow-Credentials"] = "true";
}
// Set status and range-specific headers
if (range) {
res.status(206);
headers["Content-Range"] = `bytes ${start}-${end}/${fileSize}`;
} else {
res.status(200);
}
res.set(headers);
// Create read stream with range
const stream = fs.createReadStream(filePath, { start, end });
// Handle stream errors
stream.on("error", (err) => {
logger.error(`[AudioStreaming] Stream error for ${filePath}:`, err);
if (!res.headersSent) {
res.status(500).end();
}
});
// Handle cleanup on response close
res.on("close", () => {
stream.destroy();
});
// Pipe stream to response
stream.pipe(res);
} catch (err) {
logger.error(`[AudioStreaming] Failed to stream ${filePath}:`, err);
if (!res.headersSent) {
res.status(500).end();
}
}
}
/**
* Cleanup resources
*/
+8 -3
View File
@@ -6,6 +6,7 @@
import slsk from "slsk-client";
import path from "path";
import fs from "fs";
import { mkdir } from "fs/promises";
import PQueue from "p-queue";
import { getSystemSettings } from "../utils/systemSettings";
import { sessionLog } from "../utils/playlistLogger";
@@ -700,10 +701,14 @@ class SoulseekService {
return { success: false, error: "Not connected" };
}
// Ensure destination directory exists
// Ensure destination directory exists (idempotent - won't fail if exists)
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
try {
await mkdir(destDir, { recursive: true });
} catch (err: any) {
sessionLog("SOULSEEK", `Failed to create directory ${destDir}: ${err.message}`, "ERROR");
this.activeDownloads--;
return { success: false, error: `Cannot create destination directory: ${err.message}` };
}
sessionLog(
+4 -7
View File
@@ -78,14 +78,11 @@ class WikidataService {
LIMIT 1
`;
const response = await axios.get("https://query.wikidata.org/sparql", {
const response = await this.client.get("https://query.wikidata.org/sparql", {
params: {
query: sparqlQuery,
format: "json",
},
headers: {
"User-Agent": "Lidify/1.0.0",
},
});
const bindings = response.data.results?.bindings || [];
@@ -100,7 +97,7 @@ class WikidataService {
): Promise<string | undefined> {
try {
// Get English Wikipedia article title
const response = await axios.get(
const response = await this.client.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
);
@@ -110,7 +107,7 @@ class WikidataService {
if (!enWikiTitle) return undefined;
// Get article summary from Wikipedia API
const summaryResponse = await axios.get(
const summaryResponse = await this.client.get(
"https://en.wikipedia.org/api/rest_v1/page/summary/" +
encodeURIComponent(enWikiTitle)
);
@@ -129,7 +126,7 @@ class WikidataService {
wikidataId: string
): Promise<string | undefined> {
try {
const response = await axios.get(
const response = await this.client.get(
`https://www.wikidata.org/wiki/Special:EntityData/${wikidataId}.json`
);
+5 -2
View File
@@ -121,9 +121,12 @@ async function migrateExistingSoulseekFiles(musicPath: string): Promise<void> {
continue;
}
// Create destination directory
if (!fs.existsSync(destDir)) {
// Create destination directory (idempotent - won't fail if exists)
try {
fs.mkdirSync(destDir, { recursive: true });
} catch (err: any) {
sessionLog('ORGANIZE', `Failed to create directory ${destDir}: ${err.message}`, 'WARN');
continue; // Skip this file, try next
}
// Move file (copy then delete original)