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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user