This commit is contained in:
peterino2 2026-05-31 18:36:14 -07:00
parent c58e30b30d
commit bc09479dfb
9 changed files with 448 additions and 16 deletions

1
android/MusicRoom Submodule

@ -0,0 +1 @@
Subproject commit fd6fc4d757b2f8ad4ff5e6715d6a4a9a560a35a3

143
android/dev-setup.bat Normal file
View File

@ -0,0 +1,143 @@
@echo off
echo ============================================
echo MusicRoom Android - Dev Setup
echo Installs all prerequisites via Scoop
echo ============================================
echo.
:: Ensure Scoop shims are in PATH
set "PATH=%PATH%;%USERPROFILE%\scoop\shims"
:: Step 1: Scoop
where scoop >nul 2>&1
if errorlevel 1 (
echo [1/5] Installing Scoop...
powershell -ExecutionPolicy Bypass -Command "Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force; iwr -useb get.scoop.sh | iex"
set "PATH=%PATH%;%USERPROFILE%\scoop\shims"
where scoop >nul 2>&1
if errorlevel 1 (
echo ERROR: Scoop installation failed. Install manually from https://scoop.sh
pause
exit /b 1
)
echo Scoop installed.
) else (
echo [1/5] Scoop already installed.
)
echo.
:: Step 2: Buckets
echo [2/5] Adding Scoop buckets...
call scoop bucket add java 2>nul
call scoop bucket add extras 2>nul
echo Done.
echo.
:: Step 3: Install tools
echo [3/5] Installing Node.js, JDK 17, Android CLI tools, and Gradle...
echo This may take several minutes.
echo.
call scoop install nodejs-lts
if errorlevel 1 (
echo ERROR: Failed to install Node.js
pause & exit /b 1
)
call scoop install temurin17-jdk
if errorlevel 1 (
echo ERROR: Failed to install JDK 17
pause & exit /b 1
)
call scoop install android-clt
if errorlevel 1 (
echo ERROR: Failed to install Android CLI tools
pause & exit /b 1
)
call scoop install gradle
if errorlevel 1 (
echo ERROR: Failed to install Gradle
pause & exit /b 1
)
echo All tools installed.
echo.
:: Step 4: Environment variables
echo [4/5] Setting environment variables...
set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current"
if not exist "%JAVA_HOME%" (
for /f "delims=" %%i in ('where java 2^>nul') do (
set "JAVA_PATH=%%i"
goto :found_java
)
:found_java
if defined JAVA_PATH (
for %%i in ("%JAVA_PATH%") do set "JAVA_HOME=%%~dpi.."
)
)
set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
echo JAVA_HOME = %JAVA_HOME%
echo ANDROID_HOME = %ANDROID_HOME%
powershell -Command "[Environment]::SetEnvironmentVariable('JAVA_HOME', '%JAVA_HOME%', 'User')"
powershell -Command "[Environment]::SetEnvironmentVariable('ANDROID_HOME', '%ANDROID_HOME%', 'User')"
set "SDK_TOOLS=%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\cmdline-tools\latest\bin"
powershell -Command "$p = [Environment]::GetEnvironmentVariable('Path','User'); if($p -notlike '*platform-tools*'){[Environment]::SetEnvironmentVariable('Path', $p + ';%SDK_TOOLS%', 'User')}"
set "PATH=%PATH%;%SDK_TOOLS%"
echo Done.
echo.
:: Step 5: Android SDK (headless via sdkmanager)
echo [5/5] Installing Android SDK components...
set "SDKMANAGER=%ANDROID_HOME%\cmdline-tools\latest\bin\sdkmanager.bat"
if not exist "%SDKMANAGER%" (
echo sdkmanager not found at:
echo %SDKMANAGER%
echo.
echo Trying Scoop android-clt path...
set "SDKMANAGER=%USERPROFILE%\scoop\apps\android-clt\current\bin\sdkmanager.bat"
)
if not exist "%SDKMANAGER%" (
echo ERROR: sdkmanager not found.
echo Install android-clt via Scoop first.
pause & exit /b 1
)
echo Accepting licenses...
echo y | call "%SDKMANAGER%" --licenses >nul 2>&1
echo Installing platform-tools, android-34, build-tools-34...
call "%SDKMANAGER%" "platform-tools" "platforms;android-34" "build-tools;34.0.0"
echo Done.
echo.
echo.
echo ============================================
echo Setup complete!
echo.
echo Tools installed via Scoop:
echo Node.js LTS, JDK 17, Android CLI, Gradle
echo.
echo Android SDK installed headlessly:
echo platform-tools, android-34, build-tools-34
echo.
echo Environment:
echo JAVA_HOME = %JAVA_HOME%
echo ANDROID_HOME = %ANDROID_HOME%
echo.
echo NOTE: Restart your terminal for
echo permanent env var changes.
echo ============================================
pause

43
android/run.bat Normal file
View File

@ -0,0 +1,43 @@
@echo off
echo ============================================
echo MusicRoom Android - Run App
echo ============================================
echo.
:: Ensure Scoop shims and SDK tools are in PATH
set "PATH=%PATH%;%USERPROFILE%\scoop\shims"
set "JAVA_HOME=%USERPROFILE%\scoop\apps\temurin17-jdk\current"
set "ANDROID_HOME=%LOCALAPPDATA%\Android\Sdk"
set "PATH=%PATH%;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator"
:: Start emulator if not already running
adb devices 2>nul | findstr "emulator" >nul
if errorlevel 1 (
echo Starting emulator...
start "" "%ANDROID_HOME%\emulator\emulator.exe" -avd Medium_Phone_API_36.0
echo Waiting for emulator to boot...
adb wait-for-device
:wait_boot
adb shell getprop sys.boot_completed 2>nul | findstr "1" >nul
if errorlevel 1 (
timeout /t 2 /nobreak >nul
goto :wait_boot
)
echo Emulator ready.
echo.
)
:: Build and install the app
::: Build, install, and run the app (starts Metro automatically)
echo Building and installing MusicRoom...
echo.
call bunx react-native run-android
echo.
echo ============================================
echo App is running. Leave this window open --
echo Metro is serving the JS bundle.
echo Press Ctrl+C to stop Metro when done.
echo ============================================
pause

View File

@ -5,6 +5,8 @@ export interface Track {
filename: string; // Original filename filename: string; // Original filename
title: string; // Display title title: string; // Display title
duration: number; duration: number;
replayGainDb?: number | null;
replayPeak?: number | null;
} }
export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle"; export type PlaybackMode = "once" | "repeat-all" | "repeat-one" | "shuffle";

View File

@ -13,12 +13,20 @@ export interface YtdlpConfig {
updateCheckInterval: number; updateCheckInterval: number;
} }
export interface ReplayGainConfig {
enabled: boolean;
command: string;
truePeak: boolean;
timeoutMs: number;
}
export interface Config { export interface Config {
port: number; port: number;
musicDir: string; musicDir: string;
allowGuests: boolean; allowGuests: boolean;
defaultPermissions: string[]; defaultPermissions: string[];
ytdlp?: YtdlpConfig; ytdlp?: YtdlpConfig;
replayGain?: ReplayGainConfig;
} }
const CONFIG_PATH = join(import.meta.dir, "config.json"); const CONFIG_PATH = join(import.meta.dir, "config.json");
@ -38,6 +46,12 @@ export const DEFAULT_CONFIG: Config = {
allowPlaylists: true, allowPlaylists: true,
autoUpdate: true, autoUpdate: true,
updateCheckInterval: 86400 updateCheckInterval: 86400
},
replayGain: {
enabled: true,
command: "rsgain",
truePeak: false,
timeoutMs: 120000
} }
}; };

2
db.ts
View File

@ -89,6 +89,8 @@ export interface Track {
album: string | null; album: string | null;
duration: number; duration: number;
size: number; size: number;
replayGainDb: number | null;
replayPeak: number | null;
created_at: number; created_at: number;
} }

24
init.ts
View File

@ -63,6 +63,8 @@ export function buildTracksFromIds(trackIds: string[], lib: Library): Track[] {
filename: libTrack.filename, filename: libTrack.filename,
title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""), title: libTrack.title || libTrack.filename.replace(/\.[^.]+$/, ""),
duration: libTrack.duration, duration: libTrack.duration,
replayGainDb: libTrack.replayGainDb,
replayPeak: libTrack.replayPeak,
}); });
} }
} }
@ -78,6 +80,8 @@ export function getAllLibraryTracks(lib: Library): Track[] {
filename: t.filename, filename: t.filename,
title: t.title || t.filename.replace(/\.[^.]+$/, ""), title: t.title || t.filename.replace(/\.[^.]+$/, ""),
duration: t.duration, duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
})); }));
} }
@ -110,7 +114,7 @@ export async function init(): Promise<void> {
}); });
// Initialize library // Initialize library
const library = new Library(MUSIC_DIR); const library = new Library(MUSIC_DIR, "./library_cache.db", config.replayGain ?? DEFAULT_CONFIG.replayGain);
setLibrary(library); setLibrary(library);
// Track pending playlist additions (title -> {playlistId, playlistName, userId}) // Track pending playlist additions (title -> {playlistId, playlistName, userId})
@ -155,7 +159,7 @@ export async function init(): Promise<void> {
} }
// Helper to check if track matches a pending playlist addition // Helper to check if track matches a pending playlist addition
function checkPendingPlaylistAddition(track: { id: string; title?: string; filename?: string }) { function checkPendingPlaylistAddition(track: { id: string; title?: string | null; filename?: string | null }) {
if (pendingPlaylistTracks.size === 0) return; if (pendingPlaylistTracks.size === 0) return;
const trackTitle = normalizeForMatch(track.title || ""); const trackTitle = normalizeForMatch(track.title || "");
@ -351,11 +355,19 @@ export async function init(): Promise<void> {
const allTracks = library.getAllTracks().map(t => ({ const allTracks = library.getAllTracks().map(t => ({
id: t.id, id: t.id,
title: t.title, title: t.title,
duration: t.duration duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
})); }));
broadcastToAll({ broadcastToAll({
type: "track_added", type: "track_added",
track: { id: track.id, title: track.title, duration: track.duration }, track: {
id: track.id,
title: track.title,
duration: track.duration,
replayGainDb: track.replayGainDb,
replayPeak: track.replayPeak,
},
library: allTracks library: allTracks
}); });
}); });
@ -366,7 +378,9 @@ export async function init(): Promise<void> {
const allTracks = library.getAllTracks().map(t => ({ const allTracks = library.getAllTracks().map(t => ({
id: t.id, id: t.id,
title: t.title, title: t.title,
duration: t.duration duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
})); }));
broadcastToAll({ broadcastToAll({
type: "track_removed", type: "track_removed",

View File

@ -1,4 +1,5 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { spawn } from "child_process";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { watch, type FSWatcher } from "fs"; import { watch, type FSWatcher } from "fs";
import { readdir, stat } from "fs/promises"; import { readdir, stat } from "fs/promises";
@ -8,6 +9,24 @@ import { type Track } from "./db";
const HASH_CHUNK_SIZE = 64 * 1024; // 64KB const HASH_CHUNK_SIZE = 64 * 1024; // 64KB
const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]); const AUDIO_EXTENSIONS = new Set([".mp3", ".ogg", ".flac", ".wav", ".m4a", ".aac", ".opus", ".wma", ".mp4"]);
const DEFAULT_REPLAY_GAIN_CONFIG: ReplayGainScanConfig = {
enabled: true,
command: "rsgain",
truePeak: false,
timeoutMs: 120000,
};
export interface ReplayGainScanConfig {
enabled?: boolean;
command?: string;
truePeak?: boolean;
timeoutMs?: number;
}
interface ReplayGainScanResult {
replayGainDb: number;
replayPeak: number;
}
export interface LibraryTrack extends Track { export interface LibraryTrack extends Track {
filename: string; filename: string;
@ -33,7 +52,9 @@ export class Library {
private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info private trackInfo = new Map<string, LibraryTrack>(); // trackId -> full info
private watcher: FSWatcher | null = null; private watcher: FSWatcher | null = null;
private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>(); private eventListeners = new Map<LibraryEventType, Set<LibraryEventCallback>>();
private pendingFiles = new Map<string, NodeJS.Timeout>(); // filepath -> debounce timer private pendingFiles = new Map<string, ReturnType<typeof setTimeout>>(); // filepath -> debounce timer
private replayGainConfig: Required<ReplayGainScanConfig>;
private replayGainAvailable: boolean | null = null;
// Scan progress tracking // Scan progress tracking
private _scanProgress = { scanning: false, processed: 0, total: 0 }; private _scanProgress = { scanning: false, processed: 0, total: 0 };
@ -43,9 +64,19 @@ export class Library {
return { ...this._scanProgress }; return { ...this._scanProgress };
} }
constructor(musicDir: string, cacheDbPath: string = "./library_cache.db") { constructor(
musicDir: string,
cacheDbPath: string = "./library_cache.db",
replayGainConfig: ReplayGainScanConfig = {},
) {
this.musicDir = musicDir; this.musicDir = musicDir;
this.cacheDb = new Database(cacheDbPath); this.cacheDb = new Database(cacheDbPath);
this.replayGainConfig = {
enabled: replayGainConfig.enabled ?? DEFAULT_REPLAY_GAIN_CONFIG.enabled,
command: replayGainConfig.command ?? DEFAULT_REPLAY_GAIN_CONFIG.command,
truePeak: replayGainConfig.truePeak ?? DEFAULT_REPLAY_GAIN_CONFIG.truePeak,
timeoutMs: replayGainConfig.timeoutMs ?? DEFAULT_REPLAY_GAIN_CONFIG.timeoutMs,
};
this.cacheDb.run("PRAGMA journal_mode = WAL"); this.cacheDb.run("PRAGMA journal_mode = WAL");
this.initCacheDb(); this.initCacheDb();
} }
@ -71,9 +102,13 @@ export class Library {
album TEXT, album TEXT,
duration REAL NOT NULL, duration REAL NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
replay_gain_db REAL,
replay_peak REAL,
created_at INTEGER DEFAULT (unixepoch()) created_at INTEGER DEFAULT (unixepoch())
) )
`); `);
this.addColumnIfMissing("tracks", "replay_gain_db", "REAL");
this.addColumnIfMissing("tracks", "replay_peak", "REAL");
// Library activity log // Library activity log
this.cacheDb.run(` this.cacheDb.run(`
@ -90,6 +125,12 @@ export class Library {
`); `);
this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`); this.cacheDb.run(`CREATE INDEX IF NOT EXISTS idx_library_log_time ON library_log(timestamp DESC)`);
} }
private addColumnIfMissing(table: string, column: string, definition: string): void {
const columns = this.cacheDb.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (columns.some((c) => c.name === column)) return;
this.cacheDb.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void { logActivity(action: string, track: { id?: string; filename?: string; title?: string | null }, user?: { id: number; username: string } | null): void {
this.cacheDb.query(` this.cacheDb.query(`
@ -102,15 +143,35 @@ export class Library {
return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any; return this.cacheDb.query(`SELECT * FROM library_log ORDER BY timestamp DESC LIMIT ?`).all(limit) as any;
} }
private upsertTrack(track: { id: string; title: string | null; artist: string | null; album: string | null; duration: number; size: number }): void { private upsertTrack(track: {
id: string;
title: string | null;
artist: string | null;
album: string | null;
duration: number;
size: number;
replayGainDb: number | null;
replayPeak: number | null;
}): void {
this.cacheDb.query(` this.cacheDb.query(`
INSERT INTO tracks (id, title, artist, album, duration, size) INSERT INTO tracks (id, title, artist, album, duration, size, replay_gain_db, replay_peak)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),
artist = COALESCE(excluded.artist, artist), artist = COALESCE(excluded.artist, artist),
album = COALESCE(excluded.album, album) album = COALESCE(excluded.album, album),
`).run(track.id, track.title, track.artist, track.album, track.duration, track.size); replay_gain_db = COALESCE(excluded.replay_gain_db, replay_gain_db),
replay_peak = COALESCE(excluded.replay_peak, replay_peak)
`).run(
track.id,
track.title,
track.artist,
track.album,
track.duration,
track.size,
track.replayGainDb,
track.replayPeak,
);
} }
async computeTrackId(filePath: string): Promise<string> { async computeTrackId(filePath: string): Promise<string> {
@ -157,6 +218,124 @@ export class Library {
return AUDIO_EXTENSIONS.has(ext); return AUDIO_EXTENSIONS.has(ext);
} }
private shouldAttemptReplayGain(): boolean {
return this.replayGainConfig.enabled && this.replayGainAvailable !== false;
}
private needsReplayGain(track: LibraryTrack | null | undefined): boolean {
return !track || track.replayGainDb == null || track.replayPeak == null;
}
private async ensureReplayGainAvailable(): Promise<boolean> {
if (!this.replayGainConfig.enabled) return false;
if (this.replayGainAvailable !== null) return this.replayGainAvailable;
try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, ["--version"], 10000);
const version = stdout.trim().split(/\r?\n/)[0] || "available";
console.log(`[Library] rsgain found: ${version}`);
this.replayGainAvailable = true;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`[Library] rsgain unavailable, ReplayGain scan skipped: ${message}`);
this.replayGainAvailable = false;
}
return this.replayGainAvailable;
}
private async scanReplayGain(filePath: string): Promise<ReplayGainScanResult | null> {
if (!(await this.ensureReplayGainAvailable())) return null;
const args = [
"custom",
"--output",
"--tagmode",
"s",
"--clip-mode",
"n",
"--quiet",
];
if (this.replayGainConfig.truePeak) {
args.push("--true-peak");
}
args.push(filePath);
try {
const { stdout } = await this.runCommand(this.replayGainConfig.command, args, this.replayGainConfig.timeoutMs);
return this.parseReplayGainOutput(stdout);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.warn(`[Library] rsgain failed for ${filePath}: ${message}`);
return null;
}
}
private parseReplayGainOutput(output: string): ReplayGainScanResult | null {
const lines = output
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter((line) => line.length > 0);
const headerIndex = lines.findIndex((line) => line.startsWith("Filename\t"));
if (headerIndex === -1) return null;
const dataLine = lines.slice(headerIndex + 1).find((line) => !line.startsWith("sep="));
if (!dataLine) return null;
const cells = dataLine.split("\t");
if (cells.length !== 7) return null;
const replayGainDb = Number.parseFloat(cells[2]);
const replayPeak = Number.parseFloat(cells[3]);
if (!Number.isFinite(replayGainDb) || !Number.isFinite(replayPeak)) {
return null;
}
return { replayGainDb, replayPeak };
}
private runCommand(command: string, args: string[], timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { windowsHide: true });
let stdout = "";
let stderr = "";
let finished = false;
let timeout: ReturnType<typeof setTimeout>;
const finish = (callback: () => void) => {
if (finished) return;
finished = true;
clearTimeout(timeout);
callback();
};
timeout = setTimeout(() => {
proc.kill();
finish(() => reject(new Error(`timed out after ${timeoutMs}ms`)));
}, timeoutMs);
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("error", (e) => {
finish(() => reject(e));
});
proc.on("close", (code) => {
finish(() => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(new Error(stderr.trim() || `exit code ${code}`));
}
});
});
});
}
private async processFile(filePath: string): Promise<LibraryTrack | null> { private async processFile(filePath: string): Promise<LibraryTrack | null> {
const relativePath = relative(this.musicDir, filePath); const relativePath = relative(this.musicDir, filePath);
const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath; const filename = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\\/g, "/").split("/").pop() || filePath;
@ -194,6 +373,13 @@ export class Library {
// Use defaults // Use defaults
} }
const existingTrack = this.trackInfo.get(trackId);
const replayGain = this.needsReplayGain(existingTrack)
? await this.scanReplayGain(filePath)
: null;
const replayGainDb = replayGain?.replayGainDb ?? existingTrack?.replayGainDb ?? null;
const replayPeak = replayGain?.replayPeak ?? existingTrack?.replayPeak ?? null;
const track: LibraryTrack = { const track: LibraryTrack = {
id: trackId, id: trackId,
filename, filename,
@ -203,6 +389,8 @@ export class Library {
album, album,
duration, duration,
size, size,
replayGainDb,
replayPeak,
available: true, available: true,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}; };
@ -215,6 +403,8 @@ export class Library {
album, album,
duration, duration,
size, size,
replayGainDb,
replayPeak,
}); });
return track; return track;
@ -229,7 +419,17 @@ export class Library {
// Single query joining file_cache and tracks - all in cacheDb now // Single query joining file_cache and tracks - all in cacheDb now
const cachedTracks = this.cacheDb.query(` const cachedTracks = this.cacheDb.query(`
SELECT fc.path, fc.track_id, t.title, t.artist, t.album, t.duration SELECT
fc.path,
fc.track_id,
t.title,
t.artist,
t.album,
t.duration,
t.size,
t.replay_gain_db,
t.replay_peak,
t.created_at
FROM file_cache fc FROM file_cache fc
LEFT JOIN tracks t ON fc.track_id = t.id LEFT JOIN tracks t ON fc.track_id = t.id
`).all() as Array<{ `).all() as Array<{
@ -239,6 +439,10 @@ export class Library {
artist: string | null; artist: string | null;
album: string | null; album: string | null;
duration: number | null; duration: number | null;
size: number | null;
replay_gain_db: number | null;
replay_peak: number | null;
created_at: number | null;
}>; }>;
for (const row of cachedTracks) { for (const row of cachedTracks) {
@ -253,7 +457,11 @@ export class Library {
artist: row.artist || null, artist: row.artist || null,
album: row.album || null, album: row.album || null,
duration: row.duration || 0, duration: row.duration || 0,
size: row.size || 0,
replayGainDb: row.replay_gain_db,
replayPeak: row.replay_peak,
available: true, available: true,
created_at: row.created_at || 0,
}; };
this.trackMap.set(track.id, fullPath); this.trackMap.set(track.id, fullPath);
@ -312,9 +520,12 @@ export class Library {
try { try {
const fileStat = await stat(fullPath); const fileStat = await stat(fullPath);
if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) { if (cacheEntry.size === fileStat.size && cacheEntry.mtime_ms === Math.floor(fileStat.mtimeMs)) {
skipped++; const cachedTrack = this.trackInfo.get(cacheEntry.track_id);
this._scanProgress.processed++; if (!this.shouldAttemptReplayGain() || !this.needsReplayGain(cachedTrack)) {
continue; skipped++;
this._scanProgress.processed++;
continue;
}
} }
} catch {} } catch {}
} }

View File

@ -17,6 +17,8 @@ export function handleGetLibrary(req: Request, server: any): Response {
artist: t.artist, artist: t.artist,
album: t.album, album: t.album,
duration: t.duration, duration: t.duration,
replayGainDb: t.replayGainDb,
replayPeak: t.replayPeak,
available: t.available, available: t.available,
})); }));
return Response.json(tracks, { headers }); return Response.json(tracks, { headers });